diff --git a/src/config/schema.rs b/src/config/schema.rs index 11a86126..63abe268 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -576,7 +576,9 @@ pub struct WebConfig { pub http_port: u16, /// HTTPS port pub https_port: u16, - /// Bind address + /// Bind addresses (preferred) + pub bind_addresses: Vec, + /// Bind address (legacy) pub bind_address: String, /// Enable HTTPS pub https_enabled: bool, @@ -591,6 +593,7 @@ impl Default for WebConfig { Self { http_port: 8080, https_port: 8443, + bind_addresses: Vec::new(), bind_address: "0.0.0.0".to_string(), https_enabled: false, ssl_cert_path: None, diff --git a/src/main.rs b/src/main.rs index 3d8d7cfa..d360ae80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ -use std::net::SocketAddr; +use std::collections::HashSet; +use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use axum_server::tls_rustls::RustlsConfig; use clap::{Parser, ValueEnum}; +use futures::{stream::FuturesUnordered, StreamExt}; use rustls::crypto::{ring, CryptoProvider}; use tokio::sync::broadcast; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -19,6 +21,7 @@ use one_kvm::msd::MsdController; use one_kvm::otg::{configfs, OtgService}; use one_kvm::rustdesk::RustDeskService; use one_kvm::state::AppState; +use one_kvm::utils::bind_tcp_listener; use one_kvm::video::format::{PixelFormat, Resolution}; use one_kvm::video::{Streamer, VideoStreamManager}; use one_kvm::web; @@ -134,7 +137,8 @@ async fn main() -> anyhow::Result<()> { // Apply CLI argument overrides to config (only if explicitly specified) if let Some(addr) = args.address { - config.web.bind_address = addr; + config.web.bind_address = addr.clone(); + config.web.bind_addresses = vec![addr]; } if let Some(port) = args.http_port { config.web.http_port = port; @@ -153,19 +157,18 @@ async fn main() -> anyhow::Result<()> { config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string()); } - // Log final configuration - if config.web.https_enabled { - tracing::info!( - "Server will listen on: https://{}:{}", - config.web.bind_address, - config.web.https_port - ); + let bind_ips = resolve_bind_addresses(&config.web)?; + let scheme = if config.web.https_enabled { "https" } else { "http" }; + let bind_port = if config.web.https_enabled { + config.web.https_port } else { - tracing::info!( - "Server will listen on: http://{}:{}", - config.web.bind_address, - config.web.http_port - ); + config.web.http_port + }; + + // Log final configuration + for ip in &bind_ips { + let addr = SocketAddr::new(*ip, bind_port); + tracing::info!("Server will listen on: {}://{}", scheme, addr); } // Initialize session store @@ -598,12 +601,8 @@ async fn main() -> anyhow::Result<()> { // Create router let app = web::create_router(state.clone()); - // Determine bind address based on HTTPS setting - let bind_addr: SocketAddr = if config.web.https_enabled { - format!("{}:{}", config.web.bind_address, config.web.https_port).parse()? - } else { - format!("{}:{}", config.web.bind_address, config.web.http_port).parse()? - }; + // Bind sockets for configured addresses + let listeners = bind_tcp_listeners(&bind_ips, bind_port)?; // Setup graceful shutdown let shutdown_signal = async move { @@ -640,33 +639,44 @@ async fn main() -> anyhow::Result<()> { RustlsConfig::from_pem_file(&cert_path, &key_path).await? }; - tracing::info!("Starting HTTPS server on {}", bind_addr); + let mut servers = FuturesUnordered::new(); + for listener in listeners { + let local_addr = listener.local_addr()?; + tracing::info!("Starting HTTPS server on {}", local_addr); - let server = axum_server::bind_rustls(bind_addr, tls_config).serve(app.into_make_service()); + let server = axum_server::from_tcp_rustls(listener, tls_config.clone())? + .serve(app.clone().into_make_service()); + servers.push(async move { server.await }); + } tokio::select! { _ = shutdown_signal => { cleanup(&state).await; } - result = server => { - if let Err(e) = result { + result = servers.next() => { + if let Some(Err(e)) = result { tracing::error!("HTTPS server error: {}", e); } cleanup(&state).await; } } } else { - tracing::info!("Starting HTTP server on {}", bind_addr); + let mut servers = FuturesUnordered::new(); + for listener in listeners { + let local_addr = listener.local_addr()?; + tracing::info!("Starting HTTP server on {}", local_addr); - let listener = tokio::net::TcpListener::bind(bind_addr).await?; - let server = axum::serve(listener, app); + let listener = tokio::net::TcpListener::from_std(listener)?; + let server = axum::serve(listener, app.clone()); + servers.push(async move { server.await }); + } tokio::select! { _ = shutdown_signal => { cleanup(&state).await; } - result = server => { - if let Err(e) = result { + result = servers.next() => { + if let Some(Err(e)) = result { tracing::error!("HTTP server error: {}", e); } cleanup(&state).await; @@ -719,6 +729,47 @@ fn get_data_dir() -> PathBuf { PathBuf::from("/etc/one-kvm") } +/// Resolve bind IPs from config, preferring bind_addresses when set. +fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result> { + let raw_addrs = if !web.bind_addresses.is_empty() { + web.bind_addresses.as_slice() + } else { + std::slice::from_ref(&web.bind_address) + }; + + let mut seen = HashSet::new(); + let mut addrs = Vec::new(); + for addr in raw_addrs { + let ip: IpAddr = addr + .parse() + .map_err(|_| anyhow::anyhow!("Invalid bind address: {}", addr))?; + if seen.insert(ip) { + addrs.push(ip); + } + } + + Ok(addrs) +} + +fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result> { + let mut listeners = Vec::new(); + for ip in addrs { + let addr = SocketAddr::new(*ip, port); + match bind_tcp_listener(addr) { + Ok(listener) => listeners.push(listener), + Err(err) => { + tracing::warn!("Failed to bind {}: {}", addr, err); + } + } + } + + if listeners.is_empty() { + anyhow::bail!("Failed to bind any addresses on port {}", port); + } + + Ok(listeners) +} + /// Parse video format and resolution from config (avoids code duplication) fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) { let format = config diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index 23178c47..5b636497 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -23,7 +23,7 @@ pub mod protocol; pub mod punch; pub mod rendezvous; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; @@ -37,6 +37,7 @@ use tracing::{debug, error, info, warn}; use crate::audio::AudioController; use crate::hid::HidController; use crate::video::stream_manager::VideoStreamManager; +use crate::utils::bind_tcp_listener; use self::config::RustDeskConfig; use self::connection::ConnectionManager; @@ -84,7 +85,7 @@ pub struct RustDeskService { status: Arc>, rendezvous: Arc>>>, rendezvous_handle: Arc>>>, - tcp_listener_handle: Arc>>>, + tcp_listener_handle: Arc>>>>, listen_port: Arc>, connection_manager: Arc, video_manager: Arc, @@ -212,8 +213,8 @@ impl RustDeskService { // Start TCP listener BEFORE the rendezvous mediator to ensure port is set correctly // This prevents race condition where mediator starts registration with wrong port - let (tcp_handle, listen_port) = self.start_tcp_listener_with_port().await?; - *self.tcp_listener_handle.write() = Some(tcp_handle); + let (tcp_handles, listen_port) = self.start_tcp_listener_with_port().await?; + *self.tcp_listener_handle.write() = Some(tcp_handles); // Set the listen port on mediator before starting the registration loop mediator.set_listen_port(listen_port); @@ -373,52 +374,83 @@ impl RustDeskService { /// Start TCP listener for direct peer connections /// Returns the join handle and the port that was bound - async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(JoinHandle<()>, u16)> { + async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec>, u16)> { // Try to bind to the default port, or find an available port - let listener = match TcpListener::bind(format!("0.0.0.0:{}", DIRECT_LISTEN_PORT)).await { - Ok(l) => l, - Err(_) => { - // Try binding to port 0 to get an available port - TcpListener::bind("0.0.0.0:0").await? + let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) { + Ok(result) => result, + Err(err) => { + warn!( + "Failed to bind RustDesk TCP on port {}: {}, falling back to random port", + DIRECT_LISTEN_PORT, err + ); + self.bind_direct_listeners(0)? } }; - let local_addr = listener.local_addr()?; - let listen_port = local_addr.port(); *self.listen_port.write() = listen_port; - info!("RustDesk TCP listener started on {}", local_addr); let connection_manager = self.connection_manager.clone(); - let mut shutdown_rx = self.shutdown_tx.subscribe(); + let mut handles = Vec::new(); - let handle = tokio::spawn(async move { - loop { - tokio::select! { - result = listener.accept() => { - match result { - Ok((stream, peer_addr)) => { - info!("Accepted direct connection from {}", peer_addr); - let conn_mgr = connection_manager.clone(); - tokio::spawn(async move { - if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await { - error!("Failed to handle direct connection from {}: {}", peer_addr, e); - } - }); - } - Err(e) => { - error!("TCP accept error: {}", e); + for listener in listeners { + let local_addr = listener.local_addr()?; + info!("RustDesk TCP listener started on {}", local_addr); + + let conn_mgr = connection_manager.clone(); + let mut shutdown_rx = self.shutdown_tx.subscribe(); + let handle = tokio::spawn(async move { + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, peer_addr)) => { + info!("Accepted direct connection from {}", peer_addr); + let conn_mgr = conn_mgr.clone(); + tokio::spawn(async move { + if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await { + error!("Failed to handle direct connection from {}: {}", peer_addr, e); + } + }); + } + Err(e) => { + error!("TCP accept error: {}", e); + } } } - } - _ = shutdown_rx.recv() => { - info!("TCP listener shutting down"); - break; + _ = shutdown_rx.recv() => { + info!("TCP listener shutting down"); + break; + } } } - } - }); + }); + handles.push(handle); + } - Ok((handle, listen_port)) + Ok((handles, listen_port)) + } + + fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec, u16)> { + let v4_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port); + let v4_listener = bind_tcp_listener(v4_addr)?; + let listen_port = v4_listener.local_addr()?.port(); + + let mut listeners = vec![TcpListener::from_std(v4_listener)?]; + + let v6_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), listen_port); + match bind_tcp_listener(v6_addr) { + Ok(v6_listener) => { + listeners.push(TcpListener::from_std(v6_listener)?); + } + Err(err) => { + warn!( + "IPv6 listener unavailable on port {}: {}, continuing with IPv4 only", + listen_port, err + ); + } + } + + Ok((listeners, listen_port)) } /// Stop the RustDesk service @@ -446,8 +478,10 @@ impl RustDeskService { } // Wait for TCP listener task to finish - if let Some(handle) = self.tcp_listener_handle.write().take() { - handle.abort(); + if let Some(handles) = self.tcp_listener_handle.write().take() { + for handle in handles { + handle.abort(); + } } *self.rendezvous.write() = None; diff --git a/src/rustdesk/rendezvous.rs b/src/rustdesk/rendezvous.rs index 8b411769..d347f81f 100644 --- a/src/rustdesk/rendezvous.rs +++ b/src/rustdesk/rendezvous.rs @@ -4,7 +4,7 @@ //! It registers the device ID and public key, handles punch hole requests, //! and relay requests. -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -15,6 +15,8 @@ use tokio::sync::broadcast; use tokio::time::interval; use tracing::{debug, error, info, warn}; +use crate::utils::bind_udp_socket; + use super::config::RustDeskConfig; use super::crypto::{KeyPair, SigningKeyPair}; use super::protocol::{ @@ -288,8 +290,13 @@ impl RendezvousMediator { .next() .ok_or_else(|| anyhow::anyhow!("Failed to resolve {}", addr))?; - // Create UDP socket - let socket = UdpSocket::bind("0.0.0.0:0").await?; + // Create UDP socket (match address family, enforce IPV6_V6ONLY) + let bind_addr = match server_addr { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let std_socket = bind_udp_socket(bind_addr)?; + let socket = UdpSocket::from_std(std_socket)?; socket.connect(server_addr).await?; info!("Connected to rendezvous server at {}", server_addr); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 06002c2f..12bf372a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,5 +3,7 @@ //! This module contains common utilities used across the codebase. pub mod throttle; +pub mod net; pub use throttle::LogThrottler; +pub use net::{bind_tcp_listener, bind_udp_socket}; diff --git a/src/utils/net.rs b/src/utils/net.rs new file mode 100644 index 00000000..2bd38a29 --- /dev/null +++ b/src/utils/net.rs @@ -0,0 +1,84 @@ +//! Networking helpers for binding sockets with explicit IPv6-only behavior. + +use std::io; +use std::net::{SocketAddr, TcpListener, UdpSocket}; +use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd}; + +use nix::sys::socket::{ + self, sockopt, AddressFamily, Backlog, SockFlag, SockProtocol, SockType, SockaddrIn, + SockaddrIn6, +}; + +fn socket_addr_family(addr: &SocketAddr) -> AddressFamily { + match addr { + SocketAddr::V4(_) => AddressFamily::Inet, + SocketAddr::V6(_) => AddressFamily::Inet6, + } +} + +/// Bind a TCP listener with IPv6-only set for IPv6 sockets. +pub fn bind_tcp_listener(addr: SocketAddr) -> io::Result { + let domain = socket_addr_family(&addr); + let fd = socket::socket( + domain, + SockType::Stream, + SockFlag::SOCK_CLOEXEC, + SockProtocol::Tcp, + ) + .map_err(io::Error::from)?; + + socket::setsockopt(&fd, sockopt::ReuseAddr, &true).map_err(io::Error::from)?; + + if matches!(addr, SocketAddr::V6(_)) { + socket::setsockopt(&fd, sockopt::Ipv6V6Only, &true).map_err(io::Error::from)?; + } + + match addr { + SocketAddr::V4(v4) => { + let sockaddr = SockaddrIn::from(v4); + socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?; + } + SocketAddr::V6(v6) => { + let sockaddr = SockaddrIn6::from(v6); + socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?; + } + } + socket::listen(&fd, Backlog::MAXCONN).map_err(io::Error::from)?; + + let listener = unsafe { TcpListener::from_raw_fd(fd.into_raw_fd()) }; + listener.set_nonblocking(true)?; + Ok(listener) +} + +/// Bind a UDP socket with IPv6-only set for IPv6 sockets. +pub fn bind_udp_socket(addr: SocketAddr) -> io::Result { + let domain = socket_addr_family(&addr); + let fd = socket::socket( + domain, + SockType::Datagram, + SockFlag::SOCK_CLOEXEC, + SockProtocol::Udp, + ) + .map_err(io::Error::from)?; + + socket::setsockopt(&fd, sockopt::ReuseAddr, &true).map_err(io::Error::from)?; + + if matches!(addr, SocketAddr::V6(_)) { + socket::setsockopt(&fd, sockopt::Ipv6V6Only, &true).map_err(io::Error::from)?; + } + + match addr { + SocketAddr::V4(v4) => { + let sockaddr = SockaddrIn::from(v4); + socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?; + } + SocketAddr::V6(v6) => { + let sockaddr = SockaddrIn6::from(v6); + socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?; + } + } + + let socket = unsafe { UdpSocket::from_raw_fd(fd.into_raw_fd()) }; + socket.set_nonblocking(true)?; + Ok(socket) +} diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 727b2ee2..3e500085 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -610,6 +610,7 @@ impl RustDeskConfigUpdate { pub struct WebConfigUpdate { pub http_port: Option, pub https_port: Option, + pub bind_addresses: Option>, pub bind_address: Option, pub https_enabled: Option, } @@ -626,6 +627,13 @@ impl WebConfigUpdate { return Err(AppError::BadRequest("HTTPS port cannot be 0".into())); } } + if let Some(ref addrs) = self.bind_addresses { + for addr in addrs { + if addr.parse::().is_err() { + return Err(AppError::BadRequest("Invalid bind address".into())); + } + } + } if let Some(ref addr) = self.bind_address { if addr.parse::().is_err() { return Err(AppError::BadRequest("Invalid bind address".into())); @@ -641,8 +649,16 @@ impl WebConfigUpdate { if let Some(port) = self.https_port { config.https_port = port; } - if let Some(ref addr) = self.bind_address { + if let Some(ref addrs) = self.bind_addresses { + config.bind_addresses = addrs.clone(); + if let Some(first) = addrs.first() { + config.bind_address = first.clone(); + } + } else if let Some(ref addr) = self.bind_address { config.bind_address = addr.clone(); + if config.bind_addresses.is_empty() { + config.bind_addresses = vec![addr.clone()]; + } } if let Some(enabled) = self.https_enabled { config.https_enabled = enabled; diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 77ddc52b..ea0d077f 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -316,28 +316,11 @@ fn get_network_addresses() -> Vec { Err(_) => return Vec::new(), }; - // Build a map of interface name -> IPv4 address - let mut ipv4_map: std::collections::HashMap = std::collections::HashMap::new(); - for ifaddr in all_addrs { - // Skip loopback - if ifaddr.interface_name == "lo" { - continue; - } - // Only collect IPv4 addresses (skip if already have one for this interface) - if !ipv4_map.contains_key(&ifaddr.interface_name) { - if let Some(addr) = ifaddr.address { - if let Some(sockaddr_in) = addr.as_sockaddr_in() { - ipv4_map.insert(ifaddr.interface_name.clone(), sockaddr_in.ip().to_string()); - } - } - } - } - - // Now check which interfaces are up - let mut addresses = Vec::new(); + // Check which interfaces are up + let mut up_ifaces = std::collections::HashSet::new(); let net_dir = match std::fs::read_dir("/sys/class/net") { Ok(dir) => dir, - Err(_) => return addresses, + Err(_) => return Vec::new(), }; for entry in net_dir.flatten() { @@ -361,12 +344,43 @@ fn get_network_addresses() -> Vec { continue; } - // Get IP from pre-fetched map - if let Some(ip) = ipv4_map.remove(&iface_name) { - addresses.push(NetworkAddress { - interface: iface_name, - ip, - }); + up_ifaces.insert(iface_name); + } + + let mut addresses = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for ifaddr in all_addrs { + let iface_name = &ifaddr.interface_name; + if iface_name == "lo" || !up_ifaces.contains(iface_name) { + continue; + } + + if let Some(addr) = ifaddr.address { + if let Some(sockaddr_in) = addr.as_sockaddr_in() { + let ip = sockaddr_in.ip(); + if ip.is_loopback() { + continue; + } + let ip_str = ip.to_string(); + if seen.insert((iface_name.clone(), ip_str.clone())) { + addresses.push(NetworkAddress { + interface: iface_name.clone(), + ip: ip_str, + }); + } + } else if let Some(sockaddr_in6) = addr.as_sockaddr_in6() { + let ip = sockaddr_in6.ip(); + if ip.is_loopback() || ip.is_unspecified() || ip.is_unicast_link_local() { + continue; + } + let ip_str = ip.to_string(); + if seen.insert((iface_name.clone(), ip_str.clone())) { + addresses.push(NetworkAddress { + interface: iface_name.clone(), + ip: ip_str, + }); + } + } } } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 726f205d..8edef184 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -336,6 +336,7 @@ export const rustdeskConfigApi = { export interface WebConfig { http_port: number https_port: number + bind_addresses: string[] bind_address: string https_enabled: boolean } @@ -344,6 +345,7 @@ export interface WebConfig { export interface WebConfigUpdate { http_port?: number https_port?: number + bind_addresses?: string[] bind_address?: string https_enabled?: boolean } diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index fa16c1fb..0beae2c6 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -480,10 +480,22 @@ export default { configureHttpPort: 'Configure HTTP server port', // Web server webServer: 'Access Address', - webServerDesc: 'Configure HTTP/HTTPS ports and bind address. Restart required for changes to take effect.', + webServerDesc: 'Configure HTTP/HTTPS ports and listening addresses. Restart required for changes to take effect.', httpsPort: 'HTTPS Port', bindAddress: 'Bind Address', bindAddressDesc: 'IP address the server listens on. 0.0.0.0 means all network interfaces.', + bindMode: 'Listening Address', + bindModeDesc: 'Choose which addresses the web server binds to.', + bindModeAll: 'All addresses', + bindModeLocal: 'Local only (127.0.0.1)', + bindModeCustom: 'Custom address list', + bindIpv6: 'Enable IPv6', + bindAllDesc: 'Also listen on :: (all IPv6 interfaces).', + bindLocalDesc: 'Also listen on ::1 (IPv6 loopback).', + bindAddressList: 'Address List', + bindAddressListDesc: 'One IP address per line (IPv4 or IPv6).', + addBindAddress: 'Add address', + bindAddressListEmpty: 'Add at least one IP address.', httpsEnabled: 'Enable HTTPS', httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)', restartRequired: 'Restart Required', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 01e90096..441a6b59 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -480,10 +480,22 @@ export default { configureHttpPort: '配置 HTTP 服务器端口', // Web server webServer: '访问地址', - webServerDesc: '配置 HTTP/HTTPS 端口和绑定地址,修改后需要重启生效', + webServerDesc: '配置 HTTP/HTTPS 端口和监听地址,修改后需要重启生效', httpsPort: 'HTTPS 端口', bindAddress: '绑定地址', bindAddressDesc: '服务器监听的 IP 地址,0.0.0.0 表示监听所有网络接口', + bindMode: '监听地址', + bindModeDesc: '选择 Web 服务监听哪些地址。', + bindModeAll: '所有地址', + bindModeLocal: '仅本地 (127.0.0.1)', + bindModeCustom: '自定义地址列表', + bindIpv6: '启用 IPv6', + bindAllDesc: '同时监听 ::(所有 IPv6 地址)。', + bindLocalDesc: '同时监听 ::1(IPv6 本地回环)。', + bindAddressList: '地址列表', + bindAddressListDesc: '每行一个 IP(IPv4 或 IPv6)。', + addBindAddress: '添加地址', + bindAddressListEmpty: '请至少填写一个 IP 地址。', httpsEnabled: '启用 HTTPS', httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)', restartRequired: '需要重启', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 653123b9..b2fd535a 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -282,7 +282,9 @@ export interface WebConfig { http_port: number; /** HTTPS port */ https_port: number; - /** Bind address */ + /** Bind addresses (preferred) */ + bind_addresses: string[]; + /** Bind address (legacy) */ bind_address: string; /** Enable HTTPS */ https_enabled: boolean; @@ -625,6 +627,7 @@ export interface VideoConfigUpdate { export interface WebConfigUpdate { http_port?: number; https_port?: number; + bind_addresses?: string[]; bind_address?: string; https_enabled?: boolean; } diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index a6342956..4e66f17a 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -72,6 +72,7 @@ import { Square, ChevronRight, Plus, + Trash2, ExternalLink, Copy, ScreenShare, @@ -196,11 +197,32 @@ const webServerConfig = ref({ http_port: 8080, https_port: 8443, bind_address: '0.0.0.0', + bind_addresses: ['0.0.0.0'], https_enabled: false, }) const webServerLoading = ref(false) const showRestartDialog = ref(false) const restarting = ref(false) +type BindMode = 'all' | 'loopback' | 'custom' +const bindMode = ref('all') +const bindAllIpv6 = ref(false) +const bindLocalIpv6 = ref(false) +const bindAddressList = ref([]) +const bindAddressError = computed(() => { + if (bindMode.value !== 'custom') return '' + return normalizeBindAddresses(bindAddressList.value).length + ? '' + : t('settings.bindAddressListEmpty') +}) +const effectiveBindAddresses = computed(() => { + if (bindMode.value === 'all') { + return bindAllIpv6.value ? ['0.0.0.0', '::'] : ['0.0.0.0'] + } + if (bindMode.value === 'loopback') { + return bindLocalIpv6.value ? ['127.0.0.1', '::1'] : ['127.0.0.1'] + } + return normalizeBindAddresses(bindAddressList.value) +}) // Config interface DeviceConfig { @@ -320,6 +342,12 @@ watch(() => config.value.msd_enabled, (enabled) => { } }) +watch(bindMode, (mode) => { + if (mode === 'custom' && bindAddressList.value.length === 0) { + bindAddressList.value = [''] + } +}) + // ATX config state const atxConfig = ref({ enabled: false, @@ -987,20 +1015,72 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u return `${trimmed}:${defaultPort}` } +function normalizeBindAddresses(addresses: string[]): string[] { + return addresses.map(addr => addr.trim()).filter(Boolean) +} + +function applyBindStateFromConfig(config: WebConfig) { + const rawAddrs = + config.bind_addresses && config.bind_addresses.length > 0 + ? config.bind_addresses + : config.bind_address + ? [config.bind_address] + : [] + const addrs = normalizeBindAddresses(rawAddrs) + const isAll = addrs.length > 0 && addrs.every(addr => addr === '0.0.0.0' || addr === '::') && addrs.includes('0.0.0.0') + const isLoopback = + addrs.length > 0 && + addrs.every(addr => addr === '127.0.0.1' || addr === '::1') && + addrs.includes('127.0.0.1') + if (isAll) { + bindMode.value = 'all' + bindAllIpv6.value = addrs.includes('::') + return + } + if (isLoopback) { + bindMode.value = 'loopback' + bindLocalIpv6.value = addrs.includes('::1') + return + } + bindMode.value = 'custom' + bindAddressList.value = addrs.length ? [...addrs] : [''] +} + +function addBindAddress() { + bindAddressList.value.push('') +} + +function removeBindAddress(index: number) { + bindAddressList.value.splice(index, 1) + if (bindAddressList.value.length === 0) { + bindAddressList.value.push('') + } +} + // Web server config functions async function loadWebServerConfig() { try { const config = await webConfigApi.get() webServerConfig.value = config + applyBindStateFromConfig(config) } catch (e) { console.error('Failed to load web server config:', e) } } async function saveWebServerConfig() { + if (bindAddressError.value) return webServerLoading.value = true try { - await webConfigApi.update(webServerConfig.value) + const update = { + http_port: webServerConfig.value.http_port, + https_port: webServerConfig.value.https_port, + https_enabled: webServerConfig.value.https_enabled, + bind_addresses: effectiveBindAddresses.value, + } + const updated = await webConfigApi.update(update) + webServerConfig.value = updated + applyBindStateFromConfig(updated) showRestartDialog.value = true } catch (e) { console.error('Failed to save web server config:', e) @@ -1687,13 +1767,51 @@ onMounted(async () => {
- - -

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

+ + +

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

+
+ +
+
+ +

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

+
+ +
+ +
+
+ +

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

+
+ +
+ +
+ +
+
+ + +
+ +
+

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

+

{{ bindAddressError }}

-