mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-27 16:00:49 +08:00
feat: 增加 OpenWrt 系统管理入口
- 通过 ONE_KVM_OPENWRT=1 控制入口显示和跳转; - 读取 uhttpd 当前监听配置,并跳转到 LuCI 地址。
This commit is contained in:
@@ -19,6 +19,7 @@ pub async fn health_check() -> Json<HealthResponse> {
|
|||||||
pub struct SystemInfo {
|
pub struct SystemInfo {
|
||||||
pub version: &'static str,
|
pub version: &'static str,
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
|
pub openwrt_system: bool,
|
||||||
pub platform: PlatformCapabilities,
|
pub platform: PlatformCapabilities,
|
||||||
pub capabilities: Capabilities,
|
pub capabilities: Capabilities,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[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 {
|
Json(SystemInfo {
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
initialized: config.initialized,
|
initialized: config.initialized,
|
||||||
|
openwrt_system: crate::web::openwrt_system::enabled(),
|
||||||
platform: platform.clone(),
|
platform: platform.clone(),
|
||||||
capabilities: Capabilities {
|
capabilities: Capabilities {
|
||||||
video: CapabilityInfo {
|
video: CapabilityInfo {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod audio_ws;
|
mod audio_ws;
|
||||||
mod error;
|
mod error;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
pub mod openwrt_system;
|
||||||
mod routes;
|
mod routes;
|
||||||
pub(crate) mod state;
|
pub(crate) mod state;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
|
|||||||
172
src/web/openwrt_system.rs
Normal file
172
src/web/openwrt_system.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -276,6 +276,11 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
|
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
|
||||||
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
|
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
|
||||||
|
|
||||||
|
let user_routes = user_routes.route(
|
||||||
|
"/system/openwrt",
|
||||||
|
get(super::openwrt_system::redirect_handler),
|
||||||
|
);
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let user_routes = {
|
let user_routes = {
|
||||||
user_routes
|
user_routes
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const systemApi = {
|
|||||||
request<{
|
request<{
|
||||||
version: string
|
version: string
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
|
openwrt_system: boolean
|
||||||
platform: PlatformCapabilities
|
platform: PlatformCapabilities
|
||||||
capabilities: {
|
capabilities: {
|
||||||
video: { available: boolean; backend?: string; reason?: string }
|
video: { available: boolean; backend?: string; reason?: string }
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export default {
|
|||||||
networkErrorDesc: 'Unable to connect to server. Please check your connection.',
|
networkErrorDesc: 'Unable to connect to server. Please check your connection.',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
openwrtSystemManagement: 'OpenWRT System Management',
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export default {
|
|||||||
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
|
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
openwrtSystemManagement: 'OpenWRT 系统管理',
|
||||||
logout: '退出登录',
|
logout: '退出登录',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export interface DeviceInfoEvent {
|
|||||||
|
|
||||||
export const useSystemStore = defineStore('system', () => {
|
export const useSystemStore = defineStore('system', () => {
|
||||||
const version = ref<string>('')
|
const version = ref<string>('')
|
||||||
|
const openwrtSystem = ref(false)
|
||||||
const platform = ref<PlatformCapabilities | null>(null)
|
const platform = ref<PlatformCapabilities | null>(null)
|
||||||
const capabilities = ref<SystemCapabilities | null>(null)
|
const capabilities = ref<SystemCapabilities | null>(null)
|
||||||
const diskSpace = ref<DiskSpaceInfo | null>(null)
|
const diskSpace = ref<DiskSpaceInfo | null>(null)
|
||||||
@@ -183,6 +184,7 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
try {
|
try {
|
||||||
const info = await systemApi.info()
|
const info = await systemApi.info()
|
||||||
version.value = info.version
|
version.value = info.version
|
||||||
|
openwrtSystem.value = info.openwrt_system === true
|
||||||
platform.value = info.platform
|
platform.value = info.platform
|
||||||
capabilities.value = info.capabilities
|
capabilities.value = info.capabilities
|
||||||
diskSpace.value = info.disk_space ?? null
|
diskSpace.value = info.disk_space ?? null
|
||||||
@@ -442,6 +444,7 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
|
openwrtSystem,
|
||||||
platform,
|
platform,
|
||||||
capabilities,
|
capabilities,
|
||||||
diskSpace,
|
diskSpace,
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const SETTINGS_SECTION_ID_SET = new Set<string>(SETTINGS_SECTION_IDS)
|
|||||||
|
|
||||||
const navGroups = computed(() => [
|
const navGroups = computed(() => [
|
||||||
{
|
{
|
||||||
|
id: 'software',
|
||||||
title: t('settings.software'),
|
title: t('settings.software'),
|
||||||
items: [
|
items: [
|
||||||
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
||||||
@@ -205,6 +206,7 @@ const navGroups = computed(() => [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'system',
|
||||||
title: t('settings.system'),
|
title: t('settings.system'),
|
||||||
items: [
|
items: [
|
||||||
{ id: 'video', label: t('settings.video'), icon: Monitor },
|
{ id: 'video', label: t('settings.video'), icon: Monitor },
|
||||||
@@ -215,6 +217,7 @@ const navGroups = computed(() => [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'extensions',
|
||||||
title: t('settings.extensions'),
|
title: t('settings.extensions'),
|
||||||
items: [
|
items: [
|
||||||
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
|
{ 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>
|
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent class="px-3 pb-6 md:pb-10">
|
<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>
|
<SidebarGroupLabel class="uppercase">{{ group.title }}</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem v-for="item in group.items" :key="item.id">
|
<SidebarMenuItem v-for="item in group.items" :key="item.id">
|
||||||
@@ -2778,6 +2781,15 @@ watch(isWindows, () => {
|
|||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user