From 2ebad622f0d101f1fa120c26bb9c64e151d6ebd1 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 12 Oct 2026 23:02:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20OpenWrt=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通过 ONE_KVM_OPENWRT=1 控制入口显示和跳转; - 读取 uhttpd 当前监听配置,并跳转到 LuCI 地址。 --- src/web/handlers/system.rs | 2 + src/web/mod.rs | 1 + src/web/openwrt_system.rs | 172 +++++++++++++++++++++++++++++++++ src/web/routes.rs | 5 + web/src/api/index.ts | 1 + web/src/i18n/en-US.ts | 1 + web/src/i18n/zh-CN.ts | 1 + web/src/stores/system.ts | 3 + web/src/views/SettingsView.vue | 14 ++- 9 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/web/openwrt_system.rs diff --git a/src/web/handlers/system.rs b/src/web/handlers/system.rs index 36f22c78..d0f93009 100644 --- a/src/web/handlers/system.rs +++ b/src/web/handlers/system.rs @@ -19,6 +19,7 @@ pub async fn health_check() -> Json { 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>) -> Json Json(SystemInfo { version: env!("CARGO_PKG_VERSION"), initialized: config.initialized, + openwrt_system: crate::web::openwrt_system::enabled(), platform: platform.clone(), capabilities: Capabilities { video: CapabilityInfo { diff --git a/src/web/mod.rs b/src/web/mod.rs index f657025b..ed53024b 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,6 +1,7 @@ mod audio_ws; mod error; mod handlers; +pub mod openwrt_system; mod routes; pub(crate) mod state; mod static_files; diff --git a/src/web/openwrt_system.rs b/src/web/openwrt_system.rs new file mode 100644 index 00000000..0f06e9a0 --- /dev/null +++ b/src/web/openwrt_system.rs @@ -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 { + 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::().ok().filter(|port| *port != 0) + }) +} + +fn destination(host: &str, http: &str, https: &str, redirect_https: bool) -> Option { + 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 { + 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)); + } +} diff --git a/src/web/routes.rs b/src/web/routes.rs index 448da579..35d88b83 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -276,6 +276,11 @@ pub fn create_router(state: Arc) -> 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 diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 762ebc66..de3e9b6f 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -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 } diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 6fcca31f..ad2c85bb 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -142,6 +142,7 @@ export default { networkErrorDesc: 'Unable to connect to server. Please check your connection.', }, nav: { + openwrtSystemManagement: 'OpenWRT System Management', logout: 'Logout', }, auth: { diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 0dcd24a3..8cc48a7e 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -142,6 +142,7 @@ export default { networkErrorDesc: '无法连接到服务器,请检查网络连接。', }, nav: { + openwrtSystemManagement: 'OpenWRT 系统管理', logout: '退出登录', }, auth: { diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index 2cc65694..0d615da9 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -161,6 +161,7 @@ export interface DeviceInfoEvent { export const useSystemStore = defineStore('system', () => { const version = ref('') + const openwrtSystem = ref(false) const platform = ref(null) const capabilities = ref(null) const diskSpace = ref(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, diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index d7baf011..02f50f09 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -196,6 +196,7 @@ const SETTINGS_SECTION_ID_SET = new Set(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, () => {

{{ t('settings.title') }}

- + {{ group.title }} @@ -2778,6 +2781,15 @@ watch(isWindows, () => { {{ item.label }} + + + + + +