feat: 增加 OpenWrt 系统管理入口

- 通过 ONE_KVM_OPENWRT=1 控制入口显示和跳转;
- 读取 uhttpd 当前监听配置,并跳转到 LuCI 地址。
This commit is contained in:
mofeng-git
2026-10-12 23:02:12 +08:00
parent 92f4203bc2
commit 2ebad622f0
9 changed files with 199 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ pub async fn health_check() -> Json<HealthResponse> {
pub struct SystemInfo {
pub version: &'static str,
pub initialized: bool,
pub openwrt_system: bool,
pub platform: PlatformCapabilities,
pub capabilities: Capabilities,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -66,6 +67,7 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
Json(SystemInfo {
version: env!("CARGO_PKG_VERSION"),
initialized: config.initialized,
openwrt_system: crate::web::openwrt_system::enabled(),
platform: platform.clone(),
capabilities: Capabilities {
video: CapabilityInfo {

View File

@@ -1,6 +1,7 @@
mod audio_ws;
mod error;
mod handlers;
pub mod openwrt_system;
mod routes;
pub(crate) mod state;
mod static_files;

172
src/web/openwrt_system.rs Normal file
View File

@@ -0,0 +1,172 @@
//! Read-only shortcut to the independent OpenWrt web server. No proxy or shared auth.
use axum::{
http::{header, uri::Authority, HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use std::{net::IpAddr, time::Duration};
use tokio::process::Command;
/// Explicit opt-in supplied by the OpenWrt image's service launcher.
pub fn enabled() -> bool {
cfg!(target_os = "linux") && std::env::var("ONE_KVM_OPENWRT").as_deref() == Ok("1")
}
fn listener_port(list: &str) -> Option<u16> {
list.split_whitespace().find_map(|listener| {
let (address, port) = listener.rsplit_once(':').unwrap_or(("0.0.0.0", listener));
let ip: IpAddr = address
.trim_start_matches('[')
.trim_end_matches(']')
.parse()
.ok()?;
if ip.is_loopback() {
return None;
}
port.parse::<u16>().ok().filter(|port| *port != 0)
})
}
fn destination(host: &str, http: &str, https: &str, redirect_https: bool) -> Option<String> {
let authority: Authority = host.parse().ok()?;
if authority.as_str().contains('@') {
return None;
}
let http = listener_port(http);
let https = listener_port(https);
let (scheme, port) = match (http, https, redirect_https) {
(_, Some(port), true) | (None, Some(port), _) => ("https", port),
(Some(port), _, _) => ("http", port),
_ => return None,
};
Some(format!(
"{scheme}://{}:{port}/cgi-bin/luci/",
authority.host()
))
}
async fn setting(name: &str) -> Result<String, ()> {
let output = tokio::time::timeout(
Duration::from_secs(2),
Command::new("/sbin/uci")
.args(["-q", "get", name])
.kill_on_drop(true)
.output(),
)
.await
.map_err(|_| ())?
.map_err(|_| ())?;
// Missing listen_http/listen_https is valid when that protocol is disabled.
if !output.status.success() {
return Ok(String::new());
}
String::from_utf8(output.stdout).map_err(|_| ())
}
pub async fn redirect_handler(headers: HeaderMap) -> Response {
if !enabled() {
return StatusCode::NOT_FOUND.into_response();
}
let Some(host) = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
else {
return (StatusCode::BAD_REQUEST, "Missing or invalid host").into_response();
};
let Ok((http, https, redirect)) = tokio::try_join!(
setting("uhttpd.main.listen_http"),
setting("uhttpd.main.listen_https"),
setting("uhttpd.main.redirect_https")
) else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"OpenWrt web configuration is unavailable",
)
.into_response();
};
match destination(
host,
&http,
&https,
matches!(redirect.trim(), "1" | "on" | "true" | "yes"),
) {
Some(url) => (
[(header::CACHE_CONTROL, "no-store")],
Redirect::temporary(&url),
)
.into_response(),
None => (
StatusCode::SERVICE_UNAVAILABLE,
"No public OpenWrt web listener is configured",
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn independent_http_port_uses_current_hostname() {
assert_eq!(
destination(
"device.test:8080",
"0.0.0.0:8081 [::]:8081",
"0.0.0.0:8444",
false
)
.as_deref(),
Some("http://device.test:8081/cgi-bin/luci/")
);
assert_eq!(
destination("192.168.1.11:9080", "0.0.0.0:9091", "", false).as_deref(),
Some("http://192.168.1.11:9091/cgi-bin/luci/")
);
}
#[test]
fn https_redirect_and_https_only_follow_openwrt() {
for http in ["0.0.0.0:8081", ""] {
assert_eq!(
destination("n1.test:8443", http, "0.0.0.0:9444", true).as_deref(),
Some("https://n1.test:9444/cgi-bin/luci/")
);
}
assert!(destination("n1.test", "", "[::]:8444", false)
.unwrap()
.starts_with("https://"));
assert!(destination("n1.test", "0.0.0.0:8081", "", true)
.unwrap()
.starts_with("http://"));
}
#[test]
fn ipv6_and_default_browser_ports() {
assert_eq!(
destination("[2001:db8::1]:8080", "[::]:8081", "", false).as_deref(),
Some("http://[2001:db8::1]:8081/cgi-bin/luci/")
);
assert!(destination("n1.test", "0.0.0.0:8081", "", false)
.unwrap()
.contains("n1.test:8081"));
}
#[test]
fn invalid_or_loopback_only_listeners_do_not_invent_a_port() {
for list in [
"",
"127.0.0.1:8081",
"[::1]:8081",
"0.0.0.0:0",
"0.0.0.0:99999",
"bad",
] {
assert_eq!(destination("n1.test:8080", list, "", false), None);
}
assert_eq!(
destination("user@n1.test:8080", "0.0.0.0:8081", "", false),
None
);
assert_eq!(
destination("https://n1.test/path", "0.0.0.0:8081", "", false),
None
);
assert_eq!(listener_port("127.0.0.1:8081 0.0.0.0:9091"), Some(9091));
}
}

View File

@@ -276,6 +276,11 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
let user_routes = user_routes.route(
"/system/openwrt",
get(super::openwrt_system::redirect_handler),
);
#[cfg(unix)]
let user_routes = {
user_routes

View File

@@ -138,6 +138,7 @@ export const systemApi = {
request<{
version: string
initialized: boolean
openwrt_system: boolean
platform: PlatformCapabilities
capabilities: {
video: { available: boolean; backend?: string; reason?: string }

View File

@@ -142,6 +142,7 @@ export default {
networkErrorDesc: 'Unable to connect to server. Please check your connection.',
},
nav: {
openwrtSystemManagement: 'OpenWRT System Management',
logout: 'Logout',
},
auth: {

View File

@@ -142,6 +142,7 @@ export default {
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
},
nav: {
openwrtSystemManagement: 'OpenWRT 系统管理',
logout: '退出登录',
},
auth: {

View File

@@ -161,6 +161,7 @@ export interface DeviceInfoEvent {
export const useSystemStore = defineStore('system', () => {
const version = ref<string>('')
const openwrtSystem = ref(false)
const platform = ref<PlatformCapabilities | null>(null)
const capabilities = ref<SystemCapabilities | null>(null)
const diskSpace = ref<DiskSpaceInfo | null>(null)
@@ -183,6 +184,7 @@ export const useSystemStore = defineStore('system', () => {
try {
const info = await systemApi.info()
version.value = info.version
openwrtSystem.value = info.openwrt_system === true
platform.value = info.platform
capabilities.value = info.capabilities
diskSpace.value = info.disk_space ?? null
@@ -442,6 +444,7 @@ export const useSystemStore = defineStore('system', () => {
return {
version,
openwrtSystem,
platform,
capabilities,
diskSpace,

View File

@@ -196,6 +196,7 @@ const SETTINGS_SECTION_ID_SET = new Set<string>(SETTINGS_SECTION_IDS)
const navGroups = computed(() => [
{
id: 'software',
title: t('settings.software'),
items: [
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
@@ -205,6 +206,7 @@ const navGroups = computed(() => [
]
},
{
id: 'system',
title: t('settings.system'),
items: [
{ id: 'video', label: t('settings.video'), icon: Monitor },
@@ -215,6 +217,7 @@ const navGroups = computed(() => [
]
},
{
id: 'extensions',
title: t('settings.extensions'),
items: [
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
@@ -2764,7 +2767,7 @@ watch(isWindows, () => {
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1>
</SidebarHeader>
<SidebarContent class="px-3 pb-6 md:pb-10">
<SidebarGroup v-for="group in navGroups" :key="group.title" class="px-0 py-1">
<SidebarGroup v-for="group in navGroups" :key="group.id" class="px-0 py-1">
<SidebarGroupLabel class="uppercase">{{ group.title }}</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in group.items" :key="item.id">
@@ -2778,6 +2781,15 @@ watch(isWindows, () => {
<span>{{ item.label }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="systemStore.openwrtSystem && group.id === 'extensions'">
<SidebarMenuButton as-child :tooltip="t('nav.openwrtSystemManagement')" class="h-8 px-3 text-foreground">
<a href="/api/system/openwrt" target="_blank" rel="noopener noreferrer" :title="t('nav.openwrtSystemManagement')">
<Server aria-hidden="true" />
<span class="min-w-0 flex-1 truncate">{{ t('nav.openwrtSystemManagement') }}</span>
<ExternalLink class="!size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>