From 213359b6c83971a65ea4c0b20fb6cb02920b820e Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Thu, 16 Jul 2026 19:19:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?USB=20=E7=BD=91=E5=8D=A1=E6=A1=A5=E6=8E=A5=EF=BC=9B=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20OTG=20=E7=AB=AF=E7=82=B9=E9=A2=84=E7=AE=97=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build/one-kvm.service | 2 +- src/config/schema/hid.rs | 94 +-- src/config/schema/mod.rs | 4 + src/config/schema/otg_network.rs | 85 +++ src/main.rs | 5 +- src/otg/bridge.rs | 945 +++++++++++++++++++++++++ src/otg/configfs.rs | 5 - src/otg/endpoint.rs | 79 --- src/otg/function.rs | 2 - src/otg/hid.rs | 17 - src/otg/manager.rs | 107 +-- src/otg/mod.rs | 11 +- src/otg/msd.rs | 26 +- src/otg/network.rs | 196 +++++ src/otg/self_check.rs | 111 ++- src/otg/service.rs | 273 ++++++- src/web/handlers/config/apply.rs | 100 ++- src/web/handlers/config/hid.rs | 59 +- src/web/handlers/config/mod.rs | 8 + src/web/handlers/config/msd.rs | 31 +- src/web/handlers/config/otg.rs | 166 +++++ src/web/handlers/config/otg_network.rs | 34 + src/web/handlers/config/types.rs | 45 +- src/web/handlers/devices.rs | 6 + src/web/handlers/setup.rs | 6 +- src/web/routes.rs | 17 + web/src/api/config.ts | 28 + web/src/api/index.ts | 3 +- web/src/i18n/en-US.ts | 26 +- web/src/i18n/zh-CN.ts | 26 +- web/src/stores/auth.ts | 1 - web/src/stores/config.ts | 11 + web/src/types/generated.ts | 69 +- web/src/views/SettingsView.vue | 450 ++++++------ web/src/views/SetupView.vue | 46 +- 35 files changed, 2367 insertions(+), 727 deletions(-) create mode 100644 src/config/schema/otg_network.rs create mode 100644 src/otg/bridge.rs delete mode 100644 src/otg/endpoint.rs create mode 100644 src/otg/network.rs create mode 100644 src/web/handlers/config/otg.rs create mode 100644 src/web/handlers/config/otg_network.rs diff --git a/build/one-kvm.service b/build/one-kvm.service index b3fd56c3..97c03848 100644 --- a/build/one-kvm.service +++ b/build/one-kvm.service @@ -1,7 +1,7 @@ [Unit] Description=One-KVM IP-KVM Service Documentation=https://github.com/mofeng-git/One-KVM -After=network.target +After=network-online.target Wants=network-online.target [Service] diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index c7fdbcf4..6c533566 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -81,29 +81,6 @@ pub enum OtgHidProfile { Custom, } -#[typeshare] -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum OtgEndpointBudget { - #[default] - Auto, - Five, - Six, - Unlimited, -} - -impl OtgEndpointBudget { - pub fn endpoint_limit_raw(&self) -> Option { - match self { - Self::Five => Some(5), - Self::Six => Some(6), - Self::Unlimited => None, - Self::Auto => None, - } - } -} - #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] @@ -154,26 +131,6 @@ impl OtgHidFunctions { pub fn is_empty(&self) -> bool { !self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer } - - pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 { - let mut endpoints = 0; - if self.keyboard { - endpoints += 1; - if keyboard_leds { - endpoints += 1; - } - } - if self.mouse_relative { - endpoints += 1; - } - if self.mouse_absolute { - endpoints += 1; - } - if self.consumer { - endpoints += 1; - } - endpoints - } } impl Default for OtgHidFunctions { @@ -216,8 +173,6 @@ pub struct HidConfig { #[serde(default)] pub otg_profile: OtgHidProfile, #[serde(default)] - pub otg_endpoint_budget: OtgEndpointBudget, - #[serde(default)] pub otg_functions: OtgHidFunctions, #[serde(default)] pub otg_keyboard_leds: bool, @@ -237,7 +192,6 @@ impl Default for HidConfig { otg_udc: None, otg_descriptor: OtgDescriptorConfig::default(), otg_profile: OtgHidProfile::default(), - otg_endpoint_budget: OtgEndpointBudget::default(), otg_functions: OtgHidFunctions::default(), otg_keyboard_leds: false, ch9329_port: "/dev/ttyUSB0".to_string(), @@ -262,16 +216,7 @@ impl HidConfig { self.effective_otg_functions() } - pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 { - let functions = self.effective_otg_functions(); - let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds()); - if msd_enabled { - endpoints += 2; - } - endpoints - } - - pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> { + pub fn validate_otg_functions(&self) -> crate::error::Result<()> { if self.backend != HidBackend::Otg { return Ok(()); } @@ -283,17 +228,6 @@ impl HidConfig { )); } - let resolved_limit = self.resolved_otg_endpoint_limit(); - let required = self.effective_otg_required_endpoints(msd_enabled); - if let Some(limit) = resolved_limit { - if required > limit { - return Err(crate::error::AppError::BadRequest(format!( - "OTG selection requires {} endpoints, but the configured limit is {}", - required, limit - ))); - } - } - Ok(()) } @@ -317,30 +251,4 @@ impl HidConfig { } }) } - - #[inline] - pub fn resolved_otg_endpoint_limit(&self) -> Option { - if self.backend != HidBackend::Otg { - return None; - } - match self.otg_endpoint_budget { - OtgEndpointBudget::Five => Some(5), - OtgEndpointBudget::Six => Some(6), - OtgEndpointBudget::Unlimited => None, - OtgEndpointBudget::Auto => { - #[cfg(unix)] - let udc = self.resolved_otg_udc().unwrap_or_default(); - #[cfg(unix)] - if crate::otg::configfs::is_low_endpoint_udc(&udc) { - Some(5) - } else { - Some(6) - } - #[cfg(not(unix))] - { - Some(6) - } - } - } - } } diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index 2eda5a78..2d205a71 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -8,6 +8,7 @@ mod atx; mod common; mod computer_use; mod hid; +mod otg_network; mod stream; mod web; @@ -15,6 +16,7 @@ pub use atx::*; pub use common::*; pub use computer_use::*; pub use hid::*; +pub use otg_network::*; pub use stream::*; pub use web::*; @@ -27,6 +29,7 @@ pub struct AppConfig { pub auth: AuthConfig, pub video: VideoConfig, pub hid: HidConfig, + pub otg_network: OtgNetworkConfig, pub msd: MsdConfig, pub atx: AtxConfig, pub audio: AudioConfig, @@ -44,6 +47,7 @@ impl AppConfig { pub fn enforce_invariants(&mut self) { if self.hid.backend != HidBackend::Otg { self.msd.enabled = false; + self.otg_network.enabled = false; } self.atx.normalize(); } diff --git a/src/config/schema/otg_network.rs b/src/config/schema/otg_network.rs new file mode 100644 index 00000000..88f7c9b7 --- /dev/null +++ b/src/config/schema/otg_network.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum OtgNetworkDriverMode { + #[default] + Ncm, + Ecm, + Rndis, +} + +impl OtgNetworkDriverMode { + pub fn function_name(self) -> &'static str { + match self { + Self::Ncm => "ncm", + Self::Ecm => "ecm", + Self::Rndis => "rndis", + } + } +} + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(default)] +pub struct OtgNetworkConfig { + pub enabled: bool, + pub driver_mode: OtgNetworkDriverMode, + /// Empty means select the connected NetworkManager Ethernet interface. + pub bridge_interface: String, + /// Empty values are resolved from the machine identity at runtime. + pub host_mac: String, + pub device_mac: String, +} + +impl OtgNetworkConfig { + pub fn validate(&self) -> crate::error::Result<()> { + for (name, value) in [ + ("host_mac", self.host_mac.as_str()), + ("device_mac", self.device_mac.as_str()), + ] { + if !value.is_empty() && !is_valid_unicast_mac(value) { + return Err(crate::error::AppError::BadRequest(format!( + "OTG network {name} must be a locally administered unicast MAC address" + ))); + } + } + if !self.host_mac.is_empty() + && !self.device_mac.is_empty() + && self.host_mac.eq_ignore_ascii_case(&self.device_mac) + { + return Err(crate::error::AppError::BadRequest( + "OTG network host_mac and device_mac must be different".to_string(), + )); + } + if self.bridge_interface.contains('/') || self.bridge_interface.contains('\0') { + return Err(crate::error::AppError::BadRequest( + "Invalid OTG network bridge interface".to_string(), + )); + } + Ok(()) + } +} + +fn is_valid_unicast_mac(value: &str) -> bool { + let bytes = value + .split(':') + .map(|part| u8::from_str_radix(part, 16)) + .collect::, _>>(); + matches!(bytes, Ok(ref bytes) if bytes.len() == 6 && bytes[0] & 0x03 == 0x02) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_local_unicast_mac_addresses() { + assert!(is_valid_unicast_mac("02:00:00:00:10:01")); + assert!(!is_valid_unicast_mac("01:00:00:00:10:01")); + assert!(!is_valid_unicast_mac("00:00:00:00:10:01")); + assert!(!is_valid_unicast_mac("bad")); + } +} diff --git a/src/main.rs b/src/main.rs index 6cb8f2b7..7cdbb21a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -304,7 +304,10 @@ async fn main() -> anyhow::Result<()> { tracing::info!("OTG Service created"); #[cfg(unix)] - if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await { + if let Err(e) = otg_service + .apply_config(&config.hid, &config.msd, &config.otg_network) + .await + { tracing::warn!("Failed to apply OTG config: {}", e); } diff --git a/src/otg/bridge.rs b/src/otg/bridge.rs new file mode 100644 index 00000000..3c6f018a --- /dev/null +++ b/src/otg/bridge.rs @@ -0,0 +1,945 @@ +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; +use uuid::Uuid; + +use crate::error::{AppError, Result}; + +const BRIDGE_IF: &str = "okvm-br0"; +const PROFILE_PREFIX: &str = "one-kvm-otg"; +const JOURNAL_PATH: &str = "/run/one-kvm/otg-network-bridge.json"; +const JOURNAL_VERSION: u8 = 2; +const NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT: Duration = Duration::from_secs(10); +const DHCP_IDENTITY_PROPERTIES: &[&str] = &[ + "ipv4.dhcp-client-id", + "ipv4.dhcp-iaid", + "ipv4.dhcp-hostname", + "ipv4.dhcp-fqdn", + "ipv4.dhcp-send-hostname", + "ipv4.dhcp-hostname-flags", +]; +const STATIC_IPV4_PROPERTIES: &[&str] = &[ + "ipv4.dns", + "ipv4.dns-search", + "ipv4.dns-options", + "ipv4.dns-priority", + "ipv4.routes", + "ipv4.route-table", + "ipv4.routing-rules", + "ipv4.never-default", + "ipv4.may-fail", + "ipv4.ignore-auto-routes", + "ipv4.ignore-auto-dns", +]; + +#[typeshare] +#[derive(Debug, Clone, Serialize)] +pub struct NetworkInterfaceInfo { + pub name: String, + pub interface_type: String, + pub state: String, + pub connection: String, + pub addresses: Vec, + pub has_default_route: bool, + pub bridge_supported: bool, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NetworkManagerDevice { + name: String, + interface_type: String, + state: String, + connection: String, + addresses: Vec, + has_default_route: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BridgeJournal { + version: u8, + uplink: String, + existing_bridge: bool, + original_connection_uuid: Option, + bridge_profile_uuid: Option, + uplink_profile_uuid: Option, + usb_profile_uuid: String, +} + +#[derive(Debug)] +struct TransactionProfiles { + bridge_name: String, + bridge_uuid: String, + uplink_name: String, + uplink_uuid: String, + usb_name: String, + usb_uuid: String, +} + +impl TransactionProfiles { + fn new() -> Self { + let transaction = Uuid::new_v4().simple().to_string(); + let suffix = &transaction[..12]; + Self { + bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"), + bridge_uuid: Uuid::new_v4().to_string(), + uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"), + uplink_uuid: Uuid::new_v4().to_string(), + usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"), + usb_uuid: Uuid::new_v4().to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct NetworkBridgeRuntime { + journal: BridgeJournal, +} + +impl NetworkBridgeRuntime { + pub fn activate(requested: &str, usb_interface: &str) -> Result { + ensure_command("nmcli")?; + ensure_command("ip")?; + + let interfaces = list_network_interfaces()?; + let selected = select_bridge_candidate(&interfaces, requested)?; + + prepare_device_for_network_manager(usb_interface, "ethernet")?; + + Self::activate_physical_uplink(&selected.name, &selected.connection, usb_interface) + } + + fn activate_physical_uplink( + uplink: &str, + original_connection: &str, + usb_interface: &str, + ) -> Result { + if original_connection.is_empty() || original_connection == "--" { + return Err(AppError::BadRequest(format!( + "Ethernet interface {uplink} has no active NetworkManager connection" + ))); + } + + reset_bridge_interface()?; + let original_connection_uuid = active_connection_uuid(uplink)?; + let ipv4_method = connection_value(&original_connection_uuid, "ipv4.method")?; + if !matches!(ipv4_method.as_str(), "auto" | "manual") { + return Err(AppError::BadRequest(format!( + "Connection {original_connection} uses unsupported ipv4.method={ipv4_method}" + ))); + } + let ipv6_method = connection_value(&original_connection_uuid, "ipv6.method")?; + if !matches!(ipv6_method.as_str(), "auto" | "disabled" | "ignore") { + return Err(AppError::BadRequest(format!( + "Connection {original_connection} uses unsupported ipv6.method={ipv6_method}" + ))); + } + let ipv4_metric = connection_value(&original_connection_uuid, "ipv4.route-metric")?; + let ipv6_metric = connection_value(&original_connection_uuid, "ipv6.route-metric")?; + let original_had_default_route = default_route(uplink).is_some(); + + let mac_path = Path::new("/sys/class/net").join(uplink).join("address"); + let uplink_mac = fs::read_to_string(&mac_path) + .map_err(|e| { + AppError::Internal(format!("Failed to read {}: {}", mac_path.display(), e)) + })? + .trim() + .to_string(); + + let profiles = TransactionProfiles::new(); + let journal = BridgeJournal { + version: JOURNAL_VERSION, + uplink: uplink.to_string(), + existing_bridge: false, + original_connection_uuid: Some(original_connection_uuid.clone()), + bridge_profile_uuid: Some(profiles.bridge_uuid.clone()), + uplink_profile_uuid: Some(profiles.uplink_uuid.clone()), + usb_profile_uuid: profiles.usb_uuid.clone(), + }; + write_journal(&journal)?; + + let prepare_result: Result<()> = (|| { + create_bridge_interface(&uplink_mac)?; + run_nmcli(&[ + "connection", + "add", + "type", + "bridge", + "ifname", + BRIDGE_IF, + "con-name", + &profiles.bridge_name, + "connection.uuid", + &profiles.bridge_uuid, + ])?; + run_nmcli(&[ + "connection", + "modify", + &profiles.bridge_uuid, + "connection.interface-name", + BRIDGE_IF, + "bridge.mac-address", + &uplink_mac, + "bridge.stp", + "no", + "ipv6.method", + &ipv6_method, + "connection.autoconnect", + "no", + ])?; + configure_ipv4_profile( + &original_connection_uuid, + &profiles.bridge_uuid, + &ipv4_method, + )?; + for (property, value) in [ + ("ipv4.route-metric", ipv4_metric.as_str()), + ("ipv6.route-metric", ipv6_metric.as_str()), + ] { + if !value.is_empty() && value != "-1" { + run_nmcli(&[ + "connection", + "modify", + &profiles.bridge_uuid, + property, + value, + ])?; + } + } + run_nmcli(&[ + "connection", + "add", + "type", + "ethernet", + "ifname", + uplink, + "con-name", + &profiles.uplink_name, + "connection.uuid", + &profiles.uplink_uuid, + "master", + BRIDGE_IF, + "slave-type", + "bridge", + "connection.autoconnect", + "no", + ])?; + run_nmcli(&[ + "connection", + "add", + "type", + "ethernet", + "ifname", + usb_interface, + "con-name", + &profiles.usb_name, + "connection.uuid", + &profiles.usb_uuid, + "master", + BRIDGE_IF, + "slave-type", + "bridge", + "connection.autoconnect", + "no", + ])?; + Ok(()) + })(); + if let Err(error) = prepare_result { + return Err(restore_or_combine(&journal, error)); + } + + let result = (|| { + run_nmcli(&["connection", "down", "uuid", &original_connection_uuid])?; + activate_connection( + "bridge", + &profiles.bridge_name, + &profiles.bridge_uuid, + Some(BRIDGE_IF), + )?; + activate_connection( + "uplink", + &profiles.uplink_name, + &profiles.uplink_uuid, + Some(uplink), + )?; + activate_connection( + "USB", + &profiles.usb_name, + &profiles.usb_uuid, + Some(usb_interface), + )?; + + let deadline = Instant::now() + Duration::from_secs(35); + while Instant::now() < deadline { + if first_ipv4_address(BRIDGE_IF).is_some() + && (!original_had_default_route || default_route(BRIDGE_IF).is_some()) + { + break; + } + thread::sleep(Duration::from_secs(1)); + } + let address = first_ipv4_address(BRIDGE_IF).ok_or_else(|| { + AppError::Internal( + "OTG bridge did not obtain an IPv4 address from upstream DHCP".to_string(), + ) + })?; + let route = default_route(BRIDGE_IF); + if original_had_default_route && route.is_none() { + return Err(AppError::Internal( + "OTG bridge did not obtain the original default route".to_string(), + )); + } + if let Some(route) = route.as_deref() { + if let Some(gateway) = gateway_from_route(route) { + if let Err(error) = run_command("ping", &["-c", "1", "-W", "2", gateway]) { + tracing::warn!( + "OTG bridge gateway ICMP diagnostic failed for {}: {}", + gateway, + error + ); + } + } + } + Ok(address) + })(); + + match result { + Ok(_address) => Ok(Self { journal }), + Err(error) => Err(restore_or_combine(&journal, error)), + } + } + + pub fn deactivate(&self) -> Result<()> { + restore_from_journal(&self.journal) + } + + pub fn recover_stale_transaction() -> Result<()> { + let value = match fs::read_to_string(JOURNAL_PATH) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(AppError::Internal(format!( + "Failed to read OTG network recovery journal: {error}" + ))) + } + }; + match serde_json::from_str::(&value) { + Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal), + Ok(journal) => Err(AppError::Config(format!( + "Unsupported OTG network recovery journal version {}", + journal.version + ))), + Err(error) => Err(AppError::Config(format!( + "Invalid OTG network recovery journal: {error}" + ))), + } + } +} + +pub fn list_network_interfaces() -> Result> { + let devices = enumerate_network_manager_devices()?; + Ok(bridge_candidates(devices, is_physical_network_interface)) +} + +fn enumerate_network_manager_devices() -> Result> { + ensure_command("nmcli")?; + let output = run_command( + "nmcli", + &[ + "-t", + "--escape", + "no", + "-f", + "DEVICE,TYPE,STATE,CONNECTION", + "device", + "status", + ], + )?; + let text = String::from_utf8_lossy(&output.stdout); + let mut devices = parse_network_manager_devices(&text); + for device in &mut devices { + device.addresses = ipv4_addresses(&device.name); + device.has_default_route = default_route(&device.name).is_some(); + } + Ok(devices) +} + +fn parse_network_manager_devices(text: &str) -> Vec { + let mut devices = Vec::new(); + for line in text.lines() { + let fields = line.splitn(4, ':').collect::>(); + if fields.len() != 4 || fields[0].is_empty() { + continue; + } + devices.push(NetworkManagerDevice { + name: fields[0].to_string(), + interface_type: fields[1].to_string(), + state: fields[2].to_string(), + connection: fields[3].to_string(), + addresses: Vec::new(), + has_default_route: false, + }); + } + devices +} + +fn bridge_candidates( + devices: Vec, + is_physical: impl Fn(&str) -> bool, +) -> Vec { + devices + .into_iter() + .filter(|device| { + device.interface_type == "ethernet" + && device.state == "connected" + && !device.connection.is_empty() + && device.connection != "--" + && is_physical(&device.name) + }) + .map(|device| NetworkInterfaceInfo { + name: device.name, + interface_type: device.interface_type, + state: device.state, + connection: device.connection, + addresses: device.addresses, + has_default_route: device.has_default_route, + bridge_supported: true, + reason: None, + }) + .collect() +} + +fn is_physical_network_interface(name: &str) -> bool { + Path::new("/sys/class/net") + .join(name) + .join("device") + .exists() +} + +fn select_bridge_candidate<'a>( + interfaces: &'a [NetworkInterfaceInfo], + requested: &str, +) -> Result<&'a NetworkInterfaceInfo> { + if requested.trim().is_empty() { + return interfaces + .iter() + .max_by_key(|item| item.has_default_route) + .ok_or_else(|| { + AppError::Config( + "No connected physical NetworkManager Ethernet interface is available for OTG bridging" + .to_string(), + ) + }); + } + + interfaces + .iter() + .find(|item| item.name == requested) + .ok_or_else(|| { + AppError::Config(format!( + "Network interface {requested} is not a connected physical NetworkManager Ethernet interface" + )) + }) +} + +fn restore_from_journal(journal: &BridgeJournal) -> Result<()> { + let mut errors = Vec::new(); + for (kind, profile_uuid) in [ + ("USB", Some(journal.usb_profile_uuid.as_str())), + ("uplink", journal.uplink_profile_uuid.as_deref()), + ("bridge", journal.bridge_profile_uuid.as_deref()), + ] { + let Some(profile_uuid) = profile_uuid else { + continue; + }; + if let Err(error) = delete_connection(profile_uuid) { + errors.push(format!( + "failed to remove owned {kind} profile {profile_uuid}: {error}" + )); + } + } + + if !journal.existing_bridge { + if let Err(error) = delete_bridge_interface() { + errors.push(format!( + "failed to remove owned bridge interface {BRIDGE_IF}: {error}" + )); + } + if let Some(ref original_uuid) = journal.original_connection_uuid { + if let Err(error) = run_nmcli(&[ + "connection", + "up", + "uuid", + original_uuid, + "ifname", + &journal.uplink, + ]) { + errors.push(format!( + "failed to restore original profile {original_uuid}: {error}" + )); + } + } + } + + if !errors.is_empty() { + return Err(AppError::Config(errors.join("; "))); + } + + match fs::remove_file(JOURNAL_PATH) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::Internal(format!( + "Failed to remove OTG network recovery journal: {error}" + ))), + } +} + +fn restore_or_combine(journal: &BridgeJournal, primary: AppError) -> AppError { + match restore_from_journal(journal) { + Ok(()) => primary, + Err(rollback) => AppError::Config(format!("{primary}; bridge rollback failed: {rollback}")), + } +} + +fn reset_bridge_interface() -> Result<()> { + for profile_uuid in connection_uuids()? { + if connection_value(&profile_uuid, "connection.interface-name")? == BRIDGE_IF { + tracing::warn!( + "Removing NetworkManager profile {} bound to reserved interface {}", + profile_uuid, + BRIDGE_IF + ); + run_nmcli(&["connection", "delete", "uuid", &profile_uuid])?; + } + } + delete_bridge_interface() +} + +fn create_bridge_interface(mac_address: &str) -> Result<()> { + run_command("ip", &["link", "add", "name", BRIDGE_IF, "type", "bridge"])?; + run_command( + "ip", + &["link", "set", "dev", BRIDGE_IF, "address", mac_address], + )?; + prepare_device_for_network_manager(BRIDGE_IF, "bridge") +} + +fn delete_bridge_interface() -> Result<()> { + if !Path::new("/sys/class/net").join(BRIDGE_IF).exists() { + return Ok(()); + } + run_command("ip", &["link", "delete", BRIDGE_IF, "type", "bridge"])?; + Ok(()) +} + +fn prepare_device_for_network_manager(interface: &str, expected_type: &str) -> Result<()> { + run_command("ip", &["link", "set", interface, "up"])?; + + let deadline = Instant::now() + NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT; + let mut requested_managed = false; + while Instant::now() < deadline { + match enumerate_network_manager_devices() { + Ok(devices) => { + if let Some(device) = devices.iter().find(|device| device.name == interface) { + if device.interface_type != expected_type { + return Err(AppError::BadRequest(format!( + "One-KVM interface {interface} has NetworkManager type {}, expected {expected_type}", + device.interface_type, + ))); + } + if device.state != "unmanaged" { + return Ok(()); + } + if !requested_managed { + tracing::info!( + "Marking One-KVM interface {} as managed by NetworkManager", + interface + ); + run_nmcli(&["device", "set", interface, "managed", "yes"])?; + requested_managed = true; + } + } + } + Err(error) => { + tracing::debug!( + "Waiting for NetworkManager to discover One-KVM interface {}: {}", + interface, + error + ); + } + } + thread::sleep(Duration::from_millis(100)); + } + + Err(AppError::Internal(format!( + "NetworkManager did not discover One-KVM {expected_type} interface {interface} within {} seconds", + NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT.as_secs() + ))) +} + +fn activate_connection(kind: &str, name: &str, uuid: &str, interface: Option<&str>) -> Result<()> { + let result = match interface { + Some(interface) => run_nmcli(&["connection", "up", name, "ifname", interface]), + None => run_nmcli(&["connection", "up", name]), + }; + result.map_err(|error| { + let target = interface + .map(|value| format!(" on {value}")) + .unwrap_or_default(); + AppError::Internal(format!( + "Failed to activate One-KVM {kind} profile {name} ({uuid}){target}: {error}" + )) + })?; + Ok(()) +} + +fn active_connection_uuid(interface: &str) -> Result { + let output = run_nmcli(&[ + "--escape", + "no", + "-g", + "GENERAL.CON-UUID", + "device", + "show", + interface, + ])?; + let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if uuid.is_empty() || uuid == "--" { + return Err(AppError::BadRequest(format!( + "Ethernet interface {interface} has no active NetworkManager profile UUID" + ))); + } + Ok(uuid) +} + +fn connection_uuids() -> Result> { + let output = run_nmcli(&["-t", "--escape", "no", "-f", "UUID", "connection", "show"])?; + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect()) +} + +fn delete_connection(profile_uuid: &str) -> Result<()> { + if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) { + return Ok(()); + } + run_nmcli(&["connection", "delete", "uuid", profile_uuid])?; + Ok(()) +} + +fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> { + for property in properties { + let Ok(value) = connection_value(source, property) else { + tracing::debug!( + "Skipping unsupported NetworkManager property {} while configuring OTG bridge", + property + ); + continue; + }; + if value.is_empty() || value == "--" { + continue; + } + run_nmcli(&["connection", "modify", target, property, &value])?; + } + Ok(()) +} + +fn configure_ipv4_profile(source: &str, target: &str, method: &str) -> Result<()> { + match method { + "auto" => { + run_nmcli(&["connection", "modify", target, "ipv4.method", "auto"])?; + copy_connection_properties(source, target, DHCP_IDENTITY_PROPERTIES) + } + "manual" => { + let addresses = connection_value(source, "ipv4.addresses")?; + if addresses.is_empty() || addresses == "--" { + return Err(AppError::BadRequest( + "Static IPv4 profile has no ipv4.addresses value".to_string(), + )); + } + let gateway = connection_value(source, "ipv4.gateway")?; + if gateway.is_empty() || gateway == "--" { + run_nmcli(&[ + "connection", + "modify", + target, + "ipv4.method", + "manual", + "ipv4.addresses", + &addresses, + ])?; + } else { + run_nmcli(&[ + "connection", + "modify", + target, + "ipv4.method", + "manual", + "ipv4.addresses", + &addresses, + "ipv4.gateway", + &gateway, + ])?; + } + copy_connection_properties(source, target, STATIC_IPV4_PROPERTIES) + } + _ => Err(AppError::BadRequest(format!( + "Unsupported IPv4 method {method}" + ))), + } +} + +fn write_journal(journal: &BridgeJournal) -> Result<()> { + let path = Path::new(JOURNAL_PATH); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + AppError::Internal(format!("Failed to create {}: {}", parent.display(), e)) + })?; + } + let value = serde_json::to_vec(journal) + .map_err(|e| AppError::Internal(format!("Failed to serialize bridge journal: {e}")))?; + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, value) + .map_err(|e| AppError::Internal(format!("Failed to write bridge recovery journal: {e}")))?; + fs::rename(&temporary, path) + .map_err(|e| AppError::Internal(format!("Failed to commit bridge recovery journal: {e}"))) +} + +fn connection_value(connection: &str, property: &str) -> Result { + let output = run_nmcli(&[ + "--escape", + "no", + "-g", + property, + "connection", + "show", + connection, + ])?; + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn first_ipv4_address(interface: &str) -> Option { + ipv4_addresses(interface).into_iter().next() +} + +fn ipv4_addresses(interface: &str) -> Vec { + let Ok(output) = Command::new("ip") + .args(["-4", "-o", "address", "show", "dev", interface]) + .output() + else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let fields = line.split_whitespace().collect::>(); + fields + .iter() + .position(|field| *field == "inet") + .and_then(|index| fields.get(index + 1)) + .map(|value| (*value).to_string()) + }) + .collect() +} + +fn default_route(interface: &str) -> Option { + let output = Command::new("ip") + .args(["-4", "route", "show", "default", "dev", interface]) + .output() + .ok()?; + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| !line.trim().is_empty()) + .map(str::to_string) +} + +fn gateway_from_route(route: &str) -> Option<&str> { + let fields = route.split_whitespace().collect::>(); + fields + .windows(2) + .find_map(|part| (part[0] == "via").then_some(part[1])) +} + +fn ensure_command(name: &str) -> Result<()> { + let status = Command::new(name).arg("--version").output(); + if status.is_err() { + return Err(AppError::BadRequest(format!( + "OTG bridge requires the {name} command" + ))); + } + Ok(()) +} + +fn run_nmcli(args: &[&str]) -> Result { + run_command("nmcli", args) +} + +fn run_command(command: &str, args: &[&str]) -> Result { + let output = Command::new(command) + .env("LC_ALL", "C") + .args(args) + .output() + .map_err(|e| { + AppError::Internal(format!( + "Failed to execute {command} {}: {e}", + args.join(" ") + )) + })?; + if output.status.success() { + return Ok(output); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Err(AppError::Internal(format!( + "{command} {} failed: {}", + args.join(" "), + if stderr.is_empty() { stdout } else { stderr } + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn device( + name: &str, + interface_type: &str, + state: &str, + connection: &str, + has_default_route: bool, + ) -> NetworkManagerDevice { + NetworkManagerDevice { + name: name.to_string(), + interface_type: interface_type.to_string(), + state: state.to_string(), + connection: connection.to_string(), + addresses: Vec::new(), + has_default_route, + } + } + + #[test] + fn bridge_journal_round_trip() { + let journal = BridgeJournal { + version: JOURNAL_VERSION, + uplink: "eth0".to_string(), + existing_bridge: false, + original_connection_uuid: Some("original-uuid".to_string()), + bridge_profile_uuid: Some("bridge-uuid".to_string()), + uplink_profile_uuid: Some("uplink-uuid".to_string()), + usb_profile_uuid: "usb-uuid".to_string(), + }; + let value = serde_json::to_string(&journal).unwrap(); + let decoded: BridgeJournal = serde_json::from_str(&value).unwrap(); + assert_eq!(decoded.uplink, "eth0"); + assert_eq!(decoded.bridge_profile_uuid.as_deref(), Some("bridge-uuid")); + } + + #[test] + fn transaction_profiles_use_unique_names_and_uuids() { + let first = TransactionProfiles::new(); + let second = TransactionProfiles::new(); + assert_ne!(first.bridge_name, second.bridge_name); + assert_ne!(first.bridge_uuid, second.bridge_uuid); + assert!(first.usb_name.starts_with(PROFILE_PREFIX)); + assert!(Uuid::parse_str(&first.usb_uuid).is_ok()); + } + + #[test] + fn gateway_is_optional_diagnostic_data() { + assert_eq!( + gateway_from_route("default via 192.0.2.1 dev okvm-br0"), + Some("192.0.2.1") + ); + assert_eq!(gateway_from_route("default dev okvm-br0"), None); + } + + #[test] + fn dhcp_identity_properties_include_client_id_and_hostname() { + assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-client-id")); + assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-iaid")); + assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-hostname")); + } + + #[test] + fn static_ipv4_properties_cover_dns_routes_and_policy() { + assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.dns")); + assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.routes")); + assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.route-table")); + assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.never-default")); + } + + #[test] + fn full_network_manager_enumeration_keeps_runtime_devices() { + let devices = parse_network_manager_devices( + "eth0:ethernet:connected:Wired connection 1\n\ + usb0:ethernet:disconnected:--\n\ + okvm-br0:bridge:unmanaged:--\n", + ); + + assert_eq!( + devices + .iter() + .map(|device| device.name.as_str()) + .collect::>(), + ["eth0", "usb0", "okvm-br0"] + ); + } + + #[test] + fn bridge_candidates_only_keep_connected_physical_ethernet() { + let devices = vec![ + device("eth0", "ethernet", "connected", "one-kvm-otg-uplink", false), + device("wlx76012dc07213", "wifi", "connected", "Wi-Fi", true), + device("okvm-br0", "bridge", "connected", "Bridge", true), + device("usb0", "ethernet", "connected", "USB", false), + device("lo", "loopback", "connected", "lo", false), + device("bond0", "bond", "connected", "Bond", false), + device("tun0", "tun", "connected", "Tunnel", false), + device("veth0", "ethernet", "connected", "Virtual", false), + device("eth1", "ethernet", "disconnected", "--", false), + device("eth2", "ethernet", "connected", "--", false), + device("eth3", "ethernet", "connected", "", false), + ]; + + let candidates = bridge_candidates(devices, |name| { + matches!(name, "eth0" | "eth1" | "eth2" | "eth3") + }); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].name, "eth0"); + assert!(candidates[0].bridge_supported); + assert_eq!(candidates[0].reason, None); + } + + #[test] + fn automatic_bridge_selection_prefers_default_route() { + let candidates = bridge_candidates( + vec![ + device("eth0", "ethernet", "connected", "Wired 1", false), + device("eth1", "ethernet", "connected", "Wired 2", true), + ], + |_| true, + ); + + let selected = select_bridge_candidate(&candidates, "").unwrap(); + + assert_eq!(selected.name, "eth1"); + } + + #[test] + fn bridge_selection_reports_when_no_candidate_exists() { + let error = select_bridge_candidate(&[], "").unwrap_err(); + + assert!(matches!(error, AppError::Config(_))); + assert!(error.to_string().contains("connected physical")); + } +} diff --git a/src/otg/configfs.rs b/src/otg/configfs.rs index 07bb19ec..1e95b925 100644 --- a/src/otg/configfs.rs +++ b/src/otg/configfs.rs @@ -75,11 +75,6 @@ fn collect_dir_names(path: &Path, devices: &mut Vec) { } } -pub fn is_low_endpoint_udc(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - name.contains("musb") || name.contains("musb-hdrc") -} - /// Sysfs/configfs: one write syscall with final buffer (incl. newline when needed). pub fn write_file(path: &Path, content: &str) -> Result<()> { let mut file = OpenOptions::new() diff --git a/src/otg/endpoint.rs b/src/otg/endpoint.rs deleted file mode 100644 index 3b650409..00000000 --- a/src/otg/endpoint.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::error::{AppError, Result}; - -pub const DEFAULT_MAX_ENDPOINTS: u8 = 16; - -#[derive(Debug, Clone)] -pub struct EndpointAllocator { - max_endpoints: u8, - used_endpoints: u8, -} - -impl EndpointAllocator { - pub fn new(max_endpoints: u8) -> Self { - Self { - max_endpoints, - used_endpoints: 0, - } - } - - pub fn allocate(&mut self, count: u8) -> Result<()> { - if self.used_endpoints + count > self.max_endpoints { - return Err(AppError::Internal(format!( - "Not enough endpoints: need {}, available {}", - count, - self.available() - ))); - } - self.used_endpoints += count; - Ok(()) - } - - pub fn release(&mut self, count: u8) { - self.used_endpoints = self.used_endpoints.saturating_sub(count); - } - - pub fn available(&self) -> u8 { - self.max_endpoints.saturating_sub(self.used_endpoints) - } - - pub fn used(&self) -> u8 { - self.used_endpoints - } - - pub fn max(&self) -> u8 { - self.max_endpoints - } - - pub fn can_allocate(&self, count: u8) -> bool { - self.available() >= count - } -} - -impl Default for EndpointAllocator { - fn default() -> Self { - Self::new(DEFAULT_MAX_ENDPOINTS) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_allocator() { - let mut alloc = EndpointAllocator::new(8); - assert_eq!(alloc.available(), 8); - - alloc.allocate(2).unwrap(); - assert_eq!(alloc.available(), 6); - assert_eq!(alloc.used(), 2); - - alloc.allocate(4).unwrap(); - assert_eq!(alloc.available(), 2); - - assert!(alloc.allocate(3).is_err()); - - alloc.release(2); - assert_eq!(alloc.available(), 4); - } -} diff --git a/src/otg/function.rs b/src/otg/function.rs index a4f353d4..a7e35a9d 100644 --- a/src/otg/function.rs +++ b/src/otg/function.rs @@ -5,8 +5,6 @@ use crate::error::Result; pub trait GadgetFunction: Send + Sync { fn name(&self) -> &str; - fn endpoints_required(&self) -> u8; - fn create(&self, gadget_path: &Path) -> Result<()>; fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()>; diff --git a/src/otg/hid.rs b/src/otg/hid.rs index 598ce98e..fe1fbbb0 100644 --- a/src/otg/hid.rs +++ b/src/otg/hid.rs @@ -19,15 +19,6 @@ pub enum HidFunctionType { } impl HidFunctionType { - pub fn endpoints(&self) -> u8 { - match self { - HidFunctionType::Keyboard => 1, - HidFunctionType::MouseRelative => 1, - HidFunctionType::MouseAbsolute => 1, - HidFunctionType::ConsumerControl => 1, - } - } - pub fn protocol(&self) -> u8 { match self { HidFunctionType::Keyboard => 1, @@ -130,10 +121,6 @@ impl GadgetFunction for HidFunction { &self.name } - fn endpoints_required(&self) -> u8 { - self.func_type.endpoints() - } - fn create(&self, gadget_path: &Path) -> Result<()> { let func_path = self.function_path(gadget_path); create_dir(&func_path)?; @@ -197,10 +184,6 @@ mod tests { #[test] fn test_hid_function_types() { - assert_eq!(HidFunctionType::Keyboard.endpoints(), 1); - assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1); - assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1); - assert_eq!(HidFunctionType::Keyboard.report_length(false), 8); assert_eq!(HidFunctionType::Keyboard.report_length(true), 8); assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4); diff --git a/src/otg/manager.rs b/src/otg/manager.rs index c0bb0a5c..f4eab2d1 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -7,10 +7,11 @@ use super::configfs::{ remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, USB_BCD_USB, }; -use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS}; use super::function::GadgetFunction; use super::hid::HidFunction; use super::msd::MsdFunction; +use super::network::NetworkFunction; +use crate::config::OtgNetworkConfig; use crate::error::{AppError, Result}; const REBIND_DELAY_MS: u64 = 300; @@ -43,9 +44,9 @@ pub struct OtgGadgetManager { gadget_path: PathBuf, config_path: PathBuf, descriptor: GadgetDescriptor, - endpoint_allocator: EndpointAllocator, hid_instance: u8, msd_instance: u8, + network_instance: u8, functions: Vec>, bound_udc: Option, created_by_us: bool, @@ -53,18 +54,14 @@ pub struct OtgGadgetManager { impl OtgGadgetManager { pub fn new() -> Self { - Self::with_config(DEFAULT_GADGET_NAME, DEFAULT_MAX_ENDPOINTS) + Self::with_config(DEFAULT_GADGET_NAME) } - pub fn with_config(gadget_name: &str, max_endpoints: u8) -> Self { - Self::with_descriptor(gadget_name, max_endpoints, GadgetDescriptor::default()) + pub fn with_config(gadget_name: &str) -> Self { + Self::with_descriptor(gadget_name, GadgetDescriptor::default()) } - pub fn with_descriptor( - gadget_name: &str, - max_endpoints: u8, - descriptor: GadgetDescriptor, - ) -> Self { + pub fn with_descriptor(gadget_name: &str, descriptor: GadgetDescriptor) -> Self { let gadget_path = configfs_path().join(gadget_name); let config_path = gadget_path.join("configs/c.1"); @@ -73,9 +70,9 @@ impl OtgGadgetManager { gadget_path, config_path, descriptor, - endpoint_allocator: EndpointAllocator::new(max_endpoints), hid_instance: 0, msd_instance: 0, + network_instance: 0, functions: Vec::with_capacity(4), bound_udc: None, created_by_us: false, @@ -143,22 +140,16 @@ impl OtgGadgetManager { Ok(func_clone) } + pub fn add_network(&mut self, config: &OtgNetworkConfig) -> Result { + let func = NetworkFunction::new(self.network_instance, config)?; + let func_clone = func.clone(); + self.add_function(Box::new(func))?; + self.network_instance += 1; + Ok(func_clone) + } + fn add_function(&mut self, func: Box) -> Result<()> { - let endpoints = func.endpoints_required(); - - if !self.endpoint_allocator.can_allocate(endpoints) { - return Err(AppError::Internal(format!( - "Not enough endpoints for function {}: need {}, available {}", - func.name(), - endpoints, - self.endpoint_allocator.available() - ))); - } - - self.endpoint_allocator.allocate(endpoints)?; - self.functions.push(func); - Ok(()) } @@ -224,30 +215,51 @@ impl OtgGadgetManager { pub fn cleanup(&mut self) -> Result<()> { if !self.gadget_exists() { + self.created_by_us = false; return Ok(()); } info!("Cleaning up OTG USB Gadget: {}", self.gadget_name); + let mut errors = Vec::new(); - let _ = self.unbind(); + if let Err(error) = self.unbind() { + errors.push(format!("unbind failed: {error}")); + } for func in self.functions.iter().rev() { - let _ = func.unlink(&self.config_path); + if let Err(error) = func.unlink(&self.config_path) { + errors.push(format!("unlink {} failed: {error}", func.name())); + } } let config_strings = self.config_path.join("strings/0x409"); - let _ = remove_dir(&config_strings); - let _ = remove_dir(&self.config_path); + if let Err(error) = remove_dir(&config_strings) { + errors.push(error.to_string()); + } + if let Err(error) = remove_dir(&self.config_path) { + errors.push(error.to_string()); + } for func in self.functions.iter().rev() { - let _ = func.cleanup(&self.gadget_path); + if let Err(error) = func.cleanup(&self.gadget_path) { + errors.push(format!("cleanup {} failed: {error}", func.name())); + } } let gadget_strings = self.gadget_path.join("strings/0x409"); - let _ = remove_dir(&gadget_strings); + if let Err(error) = remove_dir(&gadget_strings) { + errors.push(error.to_string()); + } - if let Err(e) = remove_dir(&self.gadget_path) { - warn!("Could not remove gadget directory: {}", e); + if let Err(error) = remove_dir(&self.gadget_path) { + errors.push(error.to_string()); + } + + if !errors.is_empty() { + return Err(AppError::Config(format!( + "OTG gadget cleanup incomplete: {}", + errors.join("; ") + ))); } self.created_by_us = false; @@ -313,12 +325,21 @@ impl OtgGadgetManager { } fn configuration_label(&self) -> &'static str { - if self + let has_msd = self .functions .iter() - .any(|func| func.name().starts_with("mass_storage.")) - { + .any(|func| func.name().starts_with("mass_storage.")); + let has_network = self.functions.iter().any(|func| { + ["ncm.", "ecm.", "rndis."] + .iter() + .any(|prefix| func.name().starts_with(prefix)) + }); + if has_msd && has_network { + "Config 1: HID + MSD + NET" + } else if has_msd { "Config 1: HID + MSD" + } else if has_network { + "Config 1: HID + NET" } else { "Config 1: HID" } @@ -428,14 +449,12 @@ mod tests { } #[test] - fn test_endpoint_tracking() { - let mut manager = OtgGadgetManager::with_config("test", 8); + fn test_function_selection_is_not_prevalidated() { + let mut manager = OtgGadgetManager::with_config("test"); - let _ = manager.add_keyboard(false); - assert_eq!(manager.endpoint_allocator.used(), 1); - - let _ = manager.add_mouse_relative(); - let _ = manager.add_mouse_absolute(); - assert_eq!(manager.endpoint_allocator.used(), 3); + assert!(manager.add_keyboard(false).is_ok()); + assert!(manager.add_mouse_relative().is_ok()); + assert!(manager.add_mouse_absolute().is_ok()); + assert_eq!(manager.functions.len(), 3); } } diff --git a/src/otg/mod.rs b/src/otg/mod.rs index 5c8b9d17..a3dd49ea 100644 --- a/src/otg/mod.rs +++ b/src/otg/mod.rs @@ -1,8 +1,9 @@ -//! USB OTG composite gadget (HID + MSD). +//! USB OTG composite gadget (HID + MSD + Ethernet). +#[cfg(unix)] +pub mod bridge; #[cfg(unix)] pub mod configfs; -pub mod endpoint; #[cfg(unix)] pub mod function; #[cfg(unix)] @@ -11,6 +12,8 @@ pub mod hid; pub mod manager; #[cfg(unix)] pub mod msd; +#[cfg(unix)] +pub mod network; pub mod report_desc; pub mod self_check; #[cfg(unix)] @@ -21,7 +24,9 @@ pub use manager::{wait_for_hid_devices, OtgGadgetManager}; #[cfg(unix)] pub use msd::{MsdFunction, MsdLunConfig}; #[cfg(unix)] -pub use service::{HidDevicePaths, OtgService}; +pub use network::NetworkFunction; +#[cfg(unix)] +pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService}; /// List USB Device Controller names exposed by sysfs. pub fn list_udc_devices() -> Vec { diff --git a/src/otg/msd.rs b/src/otg/msd.rs index d342b5ac..a1256f50 100644 --- a/src/otg/msd.rs +++ b/src/otg/msd.rs @@ -333,10 +333,6 @@ impl GadgetFunction for MsdFunction { &self.name } - fn endpoints_required(&self) -> u8 { - 2 - } - fn create(&self, gadget_path: &Path) -> Result<()> { let func_path = self.function_path(gadget_path); create_dir(&func_path)?; @@ -375,17 +371,18 @@ impl GadgetFunction for MsdFunction { fn cleanup(&self, gadget_path: &Path) -> Result<()> { let func_path = self.function_path(gadget_path); + let mut errors = Vec::new(); let lun_paths = match self.existing_lun_paths(gadget_path) { Ok(luns) => luns, Err(e) => { - warn!("Could not enumerate MSD LUN directories: {}", e); + errors.push(format!("could not enumerate MSD LUN directories: {e}")); Vec::new() } }; for (lun, lun_path) in lun_paths { if let Err(e) = self.disconnect_lun_path(&lun_path, lun) { - warn!("Could not disconnect LUN {} during cleanup: {}", lun, e); + errors.push(format!("could not disconnect LUN {lun}: {e}")); } // lun.0 is the mass-storage function's configfs default group. It // cannot be removed directly and is released with the function. @@ -393,12 +390,19 @@ impl GadgetFunction for MsdFunction { continue; } if let Err(e) = remove_dir(&lun_path) { - warn!("Could not remove LUN {} directory: {}", lun, e); + errors.push(format!("could not remove LUN {lun} directory: {e}")); } } if let Err(e) = remove_dir(&func_path) { - warn!("Could not remove MSD function directory: {}", e); + errors.push(format!("could not remove MSD function directory: {e}")); + } + + if !errors.is_empty() { + return Err(AppError::Config(format!( + "MSD cleanup incomplete: {}", + errors.join("; ") + ))); } debug!("Cleaned up MSD function {}", self.name()); @@ -431,7 +435,6 @@ mod tests { fn test_msd_function_name() { let msd = MsdFunction::new(0, 1).unwrap(); assert_eq!(msd.name(), "mass_storage.usb0"); - assert_eq!(msd.endpoints_required(), 2); assert_eq!(msd.lun_capacity, 1); let multi = MsdFunction::new(0, 8).unwrap(); @@ -498,7 +501,7 @@ mod tests { } #[test] - fn cleanup_leaves_default_lun_for_configfs_function_removal() { + fn cleanup_reports_when_non_configfs_cannot_release_default_lun() { let temp_dir = TempDir::new().unwrap(); let func_path = temp_dir.path().join("functions/mass_storage.usb0"); for lun in 0..2 { @@ -506,8 +509,9 @@ mod tests { } let msd = MsdFunction::new(0, 1).unwrap(); - msd.cleanup(temp_dir.path()).unwrap(); + let error = msd.cleanup(temp_dir.path()).unwrap_err(); + assert!(error.to_string().contains("MSD cleanup incomplete")); assert!(func_path.join("lun.0").exists()); assert!(!func_path.join("lun.1").exists()); } diff --git a/src/otg/network.rs b/src/otg/network.rs new file mode 100644 index 00000000..255b30a2 --- /dev/null +++ b/src/otg/network.rs @@ -0,0 +1,196 @@ +use std::collections::hash_map::DefaultHasher; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use tracing::debug; + +use super::configfs::{ + create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file, +}; +use super::function::GadgetFunction; +use crate::config::{OtgNetworkConfig, OtgNetworkDriverMode}; +use crate::error::{AppError, Result}; + +#[derive(Debug, Clone)] +pub struct NetworkFunction { + name: String, + mode: OtgNetworkDriverMode, + host_mac: String, + device_mac: String, +} + +impl NetworkFunction { + pub fn new(instance: u8, config: &OtgNetworkConfig) -> Result { + config.validate()?; + let (device_mac, host_mac) = resolved_mac_pair(config); + Ok(Self { + name: format!("{}.usb{}", config.driver_mode.function_name(), instance), + mode: config.driver_mode, + host_mac, + device_mac, + }) + } + + fn function_path(&self, gadget_path: &Path) -> PathBuf { + gadget_path.join("functions").join(&self.name) + } + + pub fn interface_name(&self, gadget_path: &Path) -> Result { + let path = self.function_path(gadget_path).join("ifname"); + let value = fs::read_to_string(&path).map_err(|e| { + AppError::Internal(format!( + "Failed to read OTG network interface from {}: {}", + path.display(), + e + )) + })?; + let value = value.trim(); + if value.is_empty() || value.contains('%') { + return Err(AppError::Internal(format!( + "Kernel did not allocate an OTG network interface for {}", + self.name + ))); + } + Ok(value.to_string()) + } + + pub fn mode(&self) -> OtgNetworkDriverMode { + self.mode + } +} + +impl GadgetFunction for NetworkFunction { + fn name(&self) -> &str { + &self.name + } + + fn create(&self, gadget_path: &Path) -> Result<()> { + let function_path = self.function_path(gadget_path); + create_dir(&function_path)?; + write_file(&function_path.join("dev_addr"), &self.device_mac)?; + write_file(&function_path.join("host_addr"), &self.host_mac)?; + + // New kernels accept an unbound interface-name pattern; old kernels expose it read-only. + let _ = write_file( + &function_path.join("ifname"), + &format!("okvm-{}%d", self.mode.function_name()), + ); + + if self.mode == OtgNetworkDriverMode::Rndis { + write_file(&gadget_path.join("bDeviceClass"), "0xEF")?; + write_file(&gadget_path.join("bDeviceSubClass"), "0x02")?; + write_file(&gadget_path.join("bDeviceProtocol"), "0x01")?; + write_file(&gadget_path.join("os_desc/use"), "1")?; + write_file(&gadget_path.join("os_desc/b_vendor_code"), "0xcd")?; + write_bytes(&gadget_path.join("os_desc/qw_sign"), b"MSFT100")?; + write_file( + &function_path.join("os_desc/interface.rndis/compatible_id"), + "RNDIS", + )?; + write_file( + &function_path.join("os_desc/interface.rndis/sub_compatible_id"), + "5162001", + )?; + } + + debug!( + "Created {} OTG network function {} (device {}, host {})", + self.mode.function_name(), + self.name, + self.device_mac, + self.host_mac + ); + Ok(()) + } + + fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()> { + let function_path = self.function_path(gadget_path); + let config_link = config_path.join(&self.name); + if !config_link.exists() { + create_symlink(&function_path, &config_link)?; + } + if self.mode == OtgNetworkDriverMode::Rndis { + let os_desc_link = gadget_path.join("os_desc/c.1"); + if !os_desc_link.exists() { + create_symlink(config_path, &os_desc_link)?; + } + } + Ok(()) + } + + fn unlink(&self, config_path: &Path) -> Result<()> { + let mut errors = Vec::new(); + if self.mode == OtgNetworkDriverMode::Rndis { + if let Some(gadget_path) = config_path.parent().and_then(Path::parent) { + if let Err(error) = remove_file(&gadget_path.join("os_desc/c.1")) { + errors.push(error.to_string()); + } + } + } + if let Err(error) = remove_file(&config_path.join(&self.name)) { + errors.push(error.to_string()); + } + if errors.is_empty() { + Ok(()) + } else { + Err(AppError::Config(format!( + "Failed to unlink OTG network function {}: {}", + self.name, + errors.join("; ") + ))) + } + } + + fn cleanup(&self, gadget_path: &Path) -> Result<()> { + remove_dir(&self.function_path(gadget_path)) + } +} + +pub(crate) fn resolved_mac_pair(config: &OtgNetworkConfig) -> (String, String) { + if !config.device_mac.is_empty() && !config.host_mac.is_empty() { + return (config.device_mac.clone(), config.host_mac.clone()); + } + + let identity = fs::read_to_string("/etc/machine-id") + .or_else(|_| fs::read_to_string("/etc/hostname")) + .unwrap_or_else(|_| "one-kvm".to_string()); + let mut hasher = DefaultHasher::new(); + identity.trim().hash(&mut hasher); + let value = hasher.finish().to_be_bytes(); + let device = format!( + "02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + value[1], value[2], value[3], value[4], value[5] + ); + let host = format!( + "02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + value[1], + value[2], + value[3], + value[4], + value[5] ^ 0x01 + ); + ( + if config.device_mac.is_empty() { + device + } else { + config.device_mac.clone() + }, + if config.host_mac.is_empty() { + host + } else { + config.host_mac.clone() + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn network_function_uses_selected_driver_name() { + let function = NetworkFunction::new(0, &OtgNetworkConfig::default()).unwrap(); + assert_eq!(function.name(), "ncm.usb0"); + } +} diff --git a/src/otg/self_check.rs b/src/otg/self_check.rs index fe42cd90..31893cf0 100644 --- a/src/otg/self_check.rs +++ b/src/otg/self_check.rs @@ -150,6 +150,7 @@ fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool { /// OTG self-check status for troubleshooting USB gadget issues pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg); + let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled; let mut checks = Vec::new(); let build_response = |checks: Vec, @@ -408,13 +409,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { &mut checks, "one_kvm_gadget_exists", false, - if hid_backend_is_otg { + if gadget_expected { OtgSelfCheckLevel::Error } else { OtgSelfCheckLevel::Warn }, "Check one-kvm gadget presence", - Some("Enable OTG HID or MSD to let one-kvm gadget be created automatically"), + Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"), Some(one_kvm_path.display().to_string()), ); } @@ -484,6 +485,15 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { .filter(|name| name.starts_with("hid.usb")) .cloned() .collect::>(); + let network_functions = function_names + .iter() + .filter(|name| { + name.starts_with("ncm.usb") + || name.starts_with("ecm.usb") + || name.starts_with("rndis.usb") + }) + .cloned() + .collect::>(); if hid_functions.is_empty() { push_otg_check( &mut checks, @@ -510,6 +520,103 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { ); } + if config.otg_network.enabled { + let network_function_ok = network_functions.len() == 1; + push_otg_check( + &mut checks, + "network_function_present", + network_function_ok, + if network_function_ok { + OtgSelfCheckLevel::Info + } else { + OtgSelfCheckLevel::Error + }, + "Check USB Ethernet function creation", + Some("The configured NCM/ECM/RNDIS function must exist exactly once"), + Some(functions_path.display().to_string()), + ); + + if let Some(function_name) = network_functions.first() { + let ifname_path = functions_path.join(function_name).join("ifname"); + let ifname = read_trimmed(&ifname_path).unwrap_or_default(); + let netdev_ok = !ifname.is_empty() + && !ifname.contains('%') + && std::path::Path::new("/sys/class/net") + .join(&ifname) + .exists(); + push_otg_check( + &mut checks, + "network_netdev_present", + netdev_ok, + if netdev_ok { + OtgSelfCheckLevel::Info + } else { + OtgSelfCheckLevel::Error + }, + "Check USB Ethernet network device", + Some("Read the function ifname and verify the matching /sys/class/net entry"), + Some(ifname_path.display().to_string()), + ); + + if netdev_ok { + let master_path = std::path::Path::new("/sys/class/net") + .join(&ifname) + .join("master"); + let bridge_ok = std::fs::canonicalize(&master_path) + .ok() + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .is_some_and(|name| { + name == "okvm-br0" || name == config.otg_network.bridge_interface + }); + push_otg_check( + &mut checks, + "network_bridge_port", + bridge_ok, + if bridge_ok { + OtgSelfCheckLevel::Info + } else { + OtgSelfCheckLevel::Error + }, + "Check USB Ethernet bridge membership", + Some("The USB network interface must be a port of the selected bridge"), + Some(master_path.display().to_string()), + ); + } + + if function_name.starts_with("rndis.") { + let os_desc = one_kvm_path.join("os_desc/c.1"); + let os_desc_ok = os_desc.exists() + && read_trimmed(&one_kvm_path.join("os_desc/use")).as_deref() == Some("1") + && read_trimmed(&one_kvm_path.join("os_desc/qw_sign")).as_deref() + == Some("MSFT100") + && read_trimmed( + &functions_path + .join(function_name) + .join("os_desc/interface.rndis/compatible_id"), + ) + .is_some_and(|value| value.starts_with("RNDIS")); + push_otg_check( + &mut checks, + "rndis_os_descriptor", + os_desc_ok, + if os_desc_ok { + OtgSelfCheckLevel::Info + } else { + OtgSelfCheckLevel::Error + }, + "Check RNDIS Microsoft OS descriptor", + Some( + "RNDIS requires the OS descriptor configuration link and compatible ID", + ), + Some(os_desc.display().to_string()), + ); + } + } + } + let config_path = one_kvm_path.join("configs/c.1"); if !config_path.exists() { push_otg_check( diff --git a/src/otg/service.rs b/src/otg/service.rs index f54acb7c..707295b0 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -1,10 +1,15 @@ use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info, warn}; +use typeshare::typeshare; +use super::bridge::NetworkBridgeRuntime; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::msd::MsdFunction; -use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions}; +use crate::config::{ + HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, +}; use crate::error::{AppError, Result}; #[derive(Debug, Clone, Default)] @@ -17,6 +22,23 @@ pub struct HidDevicePaths { pub keyboard_leds_enabled: bool, } +#[typeshare] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OtgRuntimeHealth { + #[default] + Healthy, + Applying, + Degraded, +} + +#[typeshare] +#[derive(Debug, Clone, serde::Serialize)] +pub struct OtgNetworkStatus { + pub health: OtgRuntimeHealth, + pub error: Option, +} + impl HidDevicePaths { pub fn existing_paths(&self) -> Vec { [ @@ -39,7 +61,7 @@ pub(crate) struct OtgDesiredState { pub keyboard_leds: bool, pub msd_enabled: bool, pub msd_lun_capacity: u8, - pub max_endpoints: u8, + pub network: OtgNetworkConfig, } impl Default for OtgDesiredState { @@ -51,13 +73,18 @@ impl Default for OtgDesiredState { keyboard_leds: false, msd_enabled: false, msd_lun_capacity: 1, - max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS, + network: OtgNetworkConfig::default(), } } } impl OtgDesiredState { - pub(crate) fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result { + pub(crate) fn from_config( + hid: &HidConfig, + msd: &MsdConfig, + network: &OtgNetworkConfig, + ) -> Result { + network.validate()?; let hid_functions = if hid.backend == HidBackend::Otg { let functions = hid.constrained_otg_functions(); Some(functions) @@ -65,18 +92,25 @@ impl OtgDesiredState { None }; - hid.validate_otg_endpoint_budget(msd.enabled)?; - + hid.validate_otg_functions()?; + let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled; + let udc = if needs_udc { + hid.otg_udc + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(OtgGadgetManager::find_udc) + } else { + None + }; Ok(Self { - udc: hid.resolved_otg_udc(), + udc, descriptor: GadgetDescriptor::from(&hid.otg_descriptor), hid_functions, keyboard_leds: hid.effective_otg_keyboard_leds(), msd_enabled: msd.enabled, msd_lun_capacity: 1, - max_endpoints: hid - .resolved_otg_endpoint_limit() - .unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS), + network: network.clone(), }) } @@ -84,19 +118,25 @@ impl OtgDesiredState { pub fn hid_enabled(&self) -> bool { self.hid_functions.is_some() } + + #[inline] + pub fn network_enabled(&self) -> bool { + self.network.enabled + } } #[derive(Debug, Clone)] struct OtgServiceState { + pub health: OtgRuntimeHealth, pub gadget_active: bool, pub hid_enabled: bool, pub msd_enabled: bool, pub msd_lun_capacity: u8, + pub network: OtgNetworkConfig, pub configured_udc: Option, pub hid_paths: Option, pub hid_functions: Option, pub keyboard_leds_enabled: bool, - pub max_endpoints: u8, pub descriptor: Option, pub error: Option, } @@ -104,15 +144,16 @@ struct OtgServiceState { impl Default for OtgServiceState { fn default() -> Self { Self { + health: OtgRuntimeHealth::Healthy, gadget_active: false, hid_enabled: false, msd_enabled: false, msd_lun_capacity: 1, + network: OtgNetworkConfig::default(), configured_udc: None, hid_paths: None, hid_functions: None, keyboard_leds_enabled: false, - max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS, descriptor: None, error: None, } @@ -123,7 +164,9 @@ pub struct OtgService { manager: Mutex>, state: RwLock, msd_function: RwLock>, + network_bridge: Mutex>, desired: RwLock, + recovery_checked: AtomicBool, } impl OtgService { @@ -132,7 +175,9 @@ impl OtgService { manager: Mutex::new(None), state: RwLock::new(OtgServiceState::default()), msd_function: RwLock::new(None), + network_bridge: Mutex::new(None), desired: RwLock::new(OtgDesiredState::default()), + recovery_checked: AtomicBool::new(false), } } @@ -157,19 +202,75 @@ impl OtgService { self.desired.read().await.msd_lun_capacity } - pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> { + pub async fn network_status(&self) -> OtgNetworkStatus { + let state = self.state.read().await; + OtgNetworkStatus { + health: state.health, + error: state.error.clone(), + } + } + + pub async fn apply_config( + &self, + hid: &HidConfig, + msd: &MsdConfig, + network: &OtgNetworkConfig, + ) -> Result<()> { + if !self.recovery_checked.load(Ordering::SeqCst) { + if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() { + let message = format!("Failed to recover stale OTG network transaction: {error}"); + self.mark_degraded(message.clone()).await; + return Err(AppError::Config(message)); + } + self.recovery_checked.store(true, Ordering::SeqCst); + } + let previous = self.desired.read().await.clone(); let desired = self - .desired_from_config_preserving_runtime(hid, msd) + .desired_from_config_preserving_runtime(hid, msd, network) .await?; - self.apply_desired_state(desired).await + { + let mut state = self.state.write().await; + state.health = OtgRuntimeHealth::Applying; + state.error = None; + } + if let Err(error) = self.apply_desired_state(desired).await { + warn!("OTG apply failed, restoring previous desired state: {error}"); + self.mark_degraded(error.to_string()).await; + return match self.apply_desired_state(previous).await { + Ok(()) => { + self.mark_healthy().await; + Err(error) + } + Err(rollback_error) => { + let message = format!("{error}; OTG runtime rollback failed: {rollback_error}"); + self.mark_degraded(message.clone()).await; + Err(AppError::Config(message)) + } + }; + } + self.mark_healthy().await; + Ok(()) + } + + pub async fn mark_degraded(&self, error: String) { + let mut state = self.state.write().await; + state.health = OtgRuntimeHealth::Degraded; + state.error = Some(error); + } + + async fn mark_healthy(&self) { + let mut state = self.state.write().await; + state.health = OtgRuntimeHealth::Healthy; + state.error = None; } async fn desired_from_config_preserving_runtime( &self, hid: &HidConfig, msd: &MsdConfig, + network: &OtgNetworkConfig, ) -> Result { - let mut desired = OtgDesiredState::from_config(hid, msd)?; + let mut desired = OtgDesiredState::from_config(hid, msd, network)?; desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity; Ok(desired) } @@ -204,22 +305,24 @@ impl OtgService { let desired = self.desired.read().await.clone(); debug!( - "Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}", + "Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}", desired.hid_enabled(), desired.msd_enabled, + desired.network_enabled(), desired.udc ); { let state = self.state.read().await; - if state.gadget_active + if state.health != OtgRuntimeHealth::Degraded + && state.gadget_active && state.hid_enabled == desired.hid_enabled() && state.msd_enabled == desired.msd_enabled && state.msd_lun_capacity == desired.msd_lun_capacity + && state.network == desired.network && state.configured_udc == desired.udc && state.hid_functions == desired.hid_functions && state.keyboard_leds_enabled == desired.keyboard_leds - && state.max_endpoints == desired.max_endpoints && state.descriptor.as_ref() == Some(&desired.descriptor) { debug!("OTG gadget already matches desired state"); @@ -227,14 +330,24 @@ impl OtgService { } } + let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() }; + if let Some(runtime) = network_runtime { + debug!("Restoring network before OTG gadget reconcile"); + tokio::task::spawn_blocking(move || runtime.deactivate()) + .await + .map_err(|e| AppError::Internal(format!("Bridge cleanup task failed: {e}")))??; + self.network_bridge.lock().await.take(); + } + { let mut manager = self.manager.lock().await; - if let Some(mut m) = manager.take() { + if let Some(m) = manager.as_mut() { debug!("Cleaning up existing gadget before OTG reconcile"); - if let Err(e) = m.cleanup() { - warn!("Error cleaning up existing gadget: {}", e); - } + m.cleanup().map_err(|e| { + AppError::Internal(format!("Failed to clean up existing OTG gadget: {e}")) + })?; } + manager.take(); } *self.msd_function.write().await = None; @@ -245,16 +358,16 @@ impl OtgService { state.hid_enabled = false; state.msd_enabled = false; state.msd_lun_capacity = 1; + state.network = OtgNetworkConfig::default(); state.configured_udc = None; state.hid_paths = None; state.hid_functions = None; state.keyboard_leds_enabled = false; - state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS; state.descriptor = None; state.error = None; } - if !desired.hid_enabled() && !desired.msd_enabled { + if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() { info!("OTG desired state is empty, gadget removed"); return Ok(()); } @@ -276,7 +389,6 @@ impl OtgService { let mut manager = OtgGadgetManager::with_descriptor( super::configfs::DEFAULT_GADGET_NAME, - desired.max_endpoints, desired.descriptor.clone(), ); @@ -352,19 +464,61 @@ impl OtgService { None }; + let network_func = if desired.network_enabled() { + Some(manager.add_network(&desired.network).map_err(|e| { + AppError::Internal(format!("Failed to add OTG network function: {e}")) + })?) + } else { + None + }; + if let Err(e) = manager.setup() { let error = format!("Failed to setup gadget: {}", e); self.state.write().await.error = Some(error.clone()); - return Err(AppError::Internal(error)); + return Err(cleanup_manager_or_combine(&mut manager, error)); } if let Err(e) = manager.bind(&udc) { let error = format!("Failed to bind gadget to UDC {}: {}", udc, e); self.state.write().await.error = Some(error.clone()); - let _ = manager.cleanup(); - return Err(AppError::Internal(error)); + return Err(cleanup_manager_or_combine(&mut manager, error)); } + let network_usb_interface = match network_func.as_ref() { + Some(function) => match function.interface_name(manager.gadget_path()) { + Ok(interface) => Some(interface), + Err(error) => { + let message = format!("Failed to resolve OTG network interface: {error}"); + self.state.write().await.error = Some(message.clone()); + return Err(cleanup_manager_or_combine(&mut manager, message)); + } + }, + None => None, + }; + let network_runtime = if let Some(ref usb_interface) = network_usb_interface { + let requested = desired.network.bridge_interface.clone(); + let usb_interface = usb_interface.clone(); + let activation = tokio::task::spawn_blocking(move || { + NetworkBridgeRuntime::activate(&requested, &usb_interface) + }) + .await; + match activation { + Err(error) => { + let message = format!("Bridge activation task failed: {error}"); + self.state.write().await.error = Some(message.clone()); + return Err(cleanup_manager_or_combine(&mut manager, message)); + } + Ok(Ok(runtime)) => Some(runtime), + Ok(Err(error)) => { + let message = format!("Failed to activate OTG network bridge: {error}"); + self.state.write().await.error = Some(message.clone()); + return Err(cleanup_manager_or_combine(&mut manager, message)); + } + } + } else { + None + }; + if let Some(ref paths) = hid_paths { let device_paths = paths.existing_paths(); if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await { @@ -374,6 +528,7 @@ impl OtgService { *self.manager.lock().await = Some(manager); *self.msd_function.write().await = msd_func; + *self.network_bridge.lock().await = network_runtime.clone(); { let mut state = self.state.write().await; @@ -381,11 +536,11 @@ impl OtgService { state.hid_enabled = desired.hid_enabled(); state.msd_enabled = desired.msd_enabled; state.msd_lun_capacity = desired.msd_lun_capacity; + state.network = desired.network.clone(); state.configured_udc = Some(udc); state.hid_paths = hid_paths; state.hid_functions = desired.hid_functions; state.keyboard_leds_enabled = desired.keyboard_leds; - state.max_endpoints = desired.max_endpoints; state.descriptor = Some(desired.descriptor); state.error = None; } @@ -402,13 +557,22 @@ impl OtgService { *desired = OtgDesiredState::default(); } - let mut manager = self.manager.lock().await; - if let Some(mut m) = manager.take() { - if let Err(e) = m.cleanup() { - warn!("Error cleaning up gadget during shutdown: {}", e); - } + let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() }; + if let Some(runtime) = network_runtime { + tokio::task::spawn_blocking(move || runtime.deactivate()) + .await + .map_err(|e| AppError::Internal(format!("Bridge cleanup task failed: {e}")))??; + self.network_bridge.lock().await.take(); } + let mut manager = self.manager.lock().await; + if let Some(m) = manager.as_mut() { + m.cleanup().map_err(|e| { + AppError::Internal(format!("Failed to clean up gadget during shutdown: {e}")) + })?; + } + manager.take(); + *self.msd_function.write().await = None; { let mut state = self.state.write().await; @@ -420,6 +584,15 @@ impl OtgService { } } +fn cleanup_manager_or_combine(manager: &mut OtgGadgetManager, primary: String) -> AppError { + match manager.cleanup() { + Ok(()) => AppError::Internal(primary), + Err(cleanup_error) => AppError::Config(format!( + "{primary}; gadget rollback failed: {cleanup_error}" + )), + } +} + impl Default for OtgService { fn default() -> Self { Self::new() @@ -465,7 +638,11 @@ mod tests { service.desired.write().await.msd_lun_capacity = 8; let desired = service - .desired_from_config_preserving_runtime(&HidConfig::default(), &MsdConfig::default()) + .desired_from_config_preserving_runtime( + &HidConfig::default(), + &MsdConfig::default(), + &OtgNetworkConfig::default(), + ) .await .unwrap(); @@ -479,4 +656,28 @@ mod tests { multi.msd_lun_capacity = 8; assert_ne!(single, multi); } + + #[test] + fn onecloud_full_composite_is_not_rejected_before_configfs() { + let hid = HidConfig { + backend: HidBackend::Otg, + otg_udc: Some("c9040000.usb".to_string()), + ..HidConfig::default() + }; + let msd = MsdConfig { + enabled: true, + ..MsdConfig::default() + }; + let network = OtgNetworkConfig { + enabled: true, + ..OtgNetworkConfig::default() + }; + + let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap(); + + assert_eq!(desired.udc.as_deref(), Some("c9040000.usb")); + assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full())); + assert!(desired.msd_enabled); + assert!(desired.network_enabled()); + } } diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 8479ec63..35ff1e7e 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -47,21 +47,24 @@ fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> boo || old_config.otg_descriptor != new_config.otg_descriptor || old_config.constrained_otg_functions() != new_config.constrained_otg_functions() || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() - || old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit() } -async fn reconcile_otg_from_store(state: &Arc) -> Result<()> { +async fn reconcile_otg_config( + state: &Arc, + hid: &HidConfig, + msd: &MsdConfig, + network: &OtgNetworkConfig, +) -> Result<()> { #[cfg(not(unix))] { - let _ = state; + let _ = (state, hid, msd, network); Ok(()) } #[cfg(unix)] { - let config = state.config.get(); state .otg_service - .apply_config(&config.hid, &config.msd) + .apply_config(hid, msd, network) .await .map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e))) } @@ -167,11 +170,11 @@ pub async fn apply_hid_config( state: &Arc, old_config: &HidConfig, new_config: &HidConfig, + msd_config: &MsdConfig, + network_config: &OtgNetworkConfig, options: ConfigApplyOptions, ) -> Result<()> { - let current_config = state.config.get(); - let current_msd_enabled = current_config.msd.enabled && new_config.backend == HidBackend::Otg; - new_config.validate_otg_endpoint_budget(current_msd_enabled)?; + new_config.validate_otg_functions()?; let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; let old_hid_functions = old_config.constrained_otg_functions(); @@ -179,8 +182,6 @@ pub async fn apply_hid_config( let hid_functions_changed = old_hid_functions != new_hid_functions; let keyboard_leds_changed = old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); - let endpoint_budget_changed = - old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit(); let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse; if old_config.backend == new_config.backend @@ -191,7 +192,6 @@ pub async fn apply_hid_config( && !descriptor_changed && !hid_functions_changed && !keyboard_leds_changed - && !endpoint_budget_changed && !options.force { tracing::info!("HID config unchanged, skipping reload"); @@ -214,7 +214,7 @@ pub async fn apply_hid_config( } if otg_config_changed { - reconcile_otg_from_store(state).await?; + reconcile_otg_config(state, new_config, msd_config, network_config).await?; } if !transitioning_away_from_otg { @@ -238,14 +238,12 @@ pub async fn apply_msd_config( state: &Arc, old_config: &MsdConfig, new_config: &MsdConfig, + hid_config: &HidConfig, + network_config: &OtgNetworkConfig, options: ConfigApplyOptions, ) -> Result<()> { - let current_config = state.config.get(); - let hid_backend_is_otg = current_config.hid.backend == HidBackend::Otg; + let hid_backend_is_otg = hid_config.backend == HidBackend::Otg; let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg; - current_config - .hid - .validate_otg_endpoint_budget(effective_new_msd_enabled)?; tracing::info!("MSD config sent, checking if reload needed..."); tracing::debug!("Old MSD config: {:?}", old_config); @@ -284,13 +282,13 @@ pub async fn apply_msd_config( if new_msd_enabled { tracing::info!("(Re)initializing MSD..."); - reconcile_otg_from_store(state).await?; + reconcile_otg_config(state, hid_config, new_config, network_config).await?; let mut msd_guard = state.msd.write().await; if let Some(msd) = msd_guard.as_mut() { - if let Err(e) = msd.shutdown().await { - tracing::warn!("MSD shutdown failed: {}", e); - } + msd.shutdown() + .await + .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?; } *msd_guard = None; drop(msd_guard); @@ -311,18 +309,17 @@ pub async fn apply_msd_config( let mut msd_guard = state.msd.write().await; if let Some(msd) = msd_guard.as_mut() { - if let Err(e) = msd.shutdown().await { - tracing::warn!("MSD shutdown failed: {}", e); - } + msd.shutdown() + .await + .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?; } *msd_guard = None; tracing::info!("MSD shutdown complete"); - reconcile_otg_from_store(state).await?; + reconcile_otg_config(state, hid_config, new_config, network_config).await?; } - let current_config = state.config.get(); - if current_config.hid.backend == HidBackend::Otg + if hid_config.backend == HidBackend::Otg && (options.force || old_msd_enabled != new_msd_enabled) { state @@ -335,6 +332,55 @@ pub async fn apply_msd_config( Ok(()) } +#[cfg(unix)] +pub async fn apply_otg_config( + state: &Arc, + old_config: &AppConfig, + new_config: &AppConfig, +) -> Result<()> { + let transitioning_away_from_otg = + old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg; + + if transitioning_away_from_otg { + apply_hid_config( + state, + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + ConfigApplyOptions::default(), + ) + .await?; + } else { + reconcile_otg_config( + state, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + ) + .await?; + apply_hid_config( + state, + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + ConfigApplyOptions::default(), + ) + .await?; + } + + apply_msd_config( + state, + &old_config.msd, + &new_config.msd, + &new_config.hid, + &new_config.otg_network, + ConfigApplyOptions::default(), + ) + .await +} + pub async fn apply_atx_config( state: &Arc, _old_config: &AtxConfig, diff --git a/src/web/handlers/config/hid.rs b/src/web/handlers/config/hid.rs index 22b78e8a..e5e67a72 100644 --- a/src/web/handlers/config/hid.rs +++ b/src/web/handlers/config/hid.rs @@ -1,12 +1,12 @@ use axum::{extract::State, Json}; use std::sync::Arc; -use crate::config::{HidBackend, HidConfig}; +use crate::config::HidConfig; use crate::error::Result; use crate::state::AppState; -use super::apply::{apply_hid_config, try_apply_lock, ConfigApplyOptions}; -use super::types::HidConfigUpdate; +use super::otg::update_otg_config_inner; +use super::types::{HidConfigUpdate, OtgConfigUpdate}; pub async fn get_hid_config(State(state): State>) -> Json { Json(state.config.get().hid.clone()) @@ -16,54 +16,13 @@ pub async fn update_hid_config( State(state): State>, Json(req): Json, ) -> Result> { - req.validate()?; - - let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; - let old_hid_config = state.config.get().hid.clone(); - - let mut staged_hid_config = old_hid_config.clone(); - req.apply_to(&mut staged_hid_config); - let descriptor_update = req - .ch9329_descriptor - .as_ref() - .map(|_| staged_hid_config.ch9329_descriptor.clone()); - if descriptor_update.is_some() { - staged_hid_config.ch9329_descriptor = old_hid_config.ch9329_descriptor.clone(); - } - - state - .config - .update(|config| { - config.hid = staged_hid_config.clone(); - config.enforce_invariants(); - }) - .await?; - - let new_hid_config = state.config.get().hid.clone(); - - apply_hid_config( + let response = update_otg_config_inner( &state, - &old_hid_config, - &new_hid_config, - ConfigApplyOptions::forced(), + OtgConfigUpdate { + hid: Some(req), + ..Default::default() + }, ) .await?; - - if let Some(descriptor) = descriptor_update { - if new_hid_config.backend != HidBackend::Ch9329 { - return Ok(Json(new_hid_config)); - } - - let actual = state.hid.apply_ch9329_descriptor(&descriptor).await?; - state - .config - .update(|config| { - config.hid.ch9329_descriptor = actual.descriptor.clone(); - config.enforce_invariants(); - }) - .await?; - return Ok(Json(state.config.get().hid.clone())); - } - - Ok(Json(new_hid_config)) + Ok(Json(response.hid)) } diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index e98b5137..c4e6ee8e 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -7,6 +7,10 @@ mod auth; mod hid; #[cfg(unix)] mod msd; +#[cfg(unix)] +mod otg; +#[cfg(unix)] +mod otg_network; mod redfish; mod rtsp; mod rustdesk; @@ -21,6 +25,10 @@ pub use auth::{get_auth_config, update_auth_config}; pub use hid::{get_hid_config, update_hid_config}; #[cfg(unix)] pub use msd::{get_msd_config, update_msd_config}; +#[cfg(unix)] +pub use otg::update_otg_config; +#[cfg(unix)] +pub use otg_network::{get_otg_network_config, get_otg_network_status, update_otg_network_config}; pub use redfish::{get_redfish_config, update_redfish_config}; pub use rtsp::{ get_rtsp_config, get_rtsp_status, start_rtsp_service, stop_rtsp_service, update_rtsp_config, diff --git a/src/web/handlers/config/msd.rs b/src/web/handlers/config/msd.rs index 783fafd4..47a37431 100644 --- a/src/web/handlers/config/msd.rs +++ b/src/web/handlers/config/msd.rs @@ -5,8 +5,8 @@ use crate::config::MsdConfig; use crate::error::Result; use crate::state::AppState; -use super::apply::{apply_msd_config, try_apply_lock, ConfigApplyOptions}; -use super::types::MsdConfigUpdate; +use super::otg::update_otg_config_inner; +use super::types::{MsdConfigUpdate, OtgConfigUpdate}; pub async fn get_msd_config(State(state): State>) -> Json { Json(state.config.get().msd.clone()) @@ -16,28 +16,13 @@ pub async fn update_msd_config( State(state): State>, Json(req): Json, ) -> Result> { - req.validate()?; - - let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; - let old_msd_config = state.config.get().msd.clone(); - - state - .config - .update(|config| { - req.apply_to(&mut config.msd); - config.enforce_invariants(); - }) - .await?; - - let new_msd_config = state.config.get().msd.clone(); - - apply_msd_config( + let response = update_otg_config_inner( &state, - &old_msd_config, - &new_msd_config, - ConfigApplyOptions::forced(), + OtgConfigUpdate { + msd: Some(req), + ..Default::default() + }, ) .await?; - - Ok(Json(new_msd_config)) + Ok(Json(response.msd)) } diff --git a/src/web/handlers/config/otg.rs b/src/web/handlers/config/otg.rs new file mode 100644 index 00000000..3cfed051 --- /dev/null +++ b/src/web/handlers/config/otg.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +use axum::{extract::State, Json}; +use serde::Serialize; +use typeshare::typeshare; + +use crate::config::{HidBackend, HidConfig, MsdConfig, OtgNetworkConfig}; +use crate::error::{AppError, Result}; +use crate::otg::OtgNetworkStatus; +use crate::state::AppState; + +use super::apply::{apply_otg_config, try_apply_lock}; +use super::types::OtgConfigUpdate; + +#[typeshare] +#[derive(Debug, Serialize)] +pub struct OtgConfigResponse { + pub hid: HidConfig, + pub msd: MsdConfig, + pub network: OtgNetworkConfig, + pub status: OtgNetworkStatus, +} + +pub async fn update_otg_config( + State(state): State>, + Json(request): Json, +) -> Result> { + update_otg_config_inner(&state, request).await.map(Json) +} + +pub(super) async fn update_otg_config_inner( + state: &Arc, + request: OtgConfigUpdate, +) -> Result { + let _guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; + + if let Some(ref update) = request.hid { + update.validate()?; + } + if let Some(ref update) = request.msd { + update.validate()?; + } + + let old_config = state.config.get(); + let mut staged_config = (*old_config).clone(); + let requested_ch9329_descriptor = request.hid.as_ref().and_then(|update| { + update.ch9329_descriptor.as_ref().map(|_| { + let mut hid = staged_config.hid.clone(); + update.apply_to(&mut hid); + hid.ch9329_descriptor + }) + }); + + if let Some(ref update) = request.hid { + update.apply_to(&mut staged_config.hid); + } + if requested_ch9329_descriptor.is_some() { + staged_config.hid.ch9329_descriptor = old_config.hid.ch9329_descriptor.clone(); + } + if let Some(ref update) = request.msd { + update.apply_to(&mut staged_config.msd); + } + if let Some(ref update) = request.network { + update.apply_to(&mut staged_config.otg_network); + } + staged_config.enforce_invariants(); + + if staged_config.otg_network.enabled + && (staged_config.otg_network.device_mac.is_empty() + || staged_config.otg_network.host_mac.is_empty()) + { + let (device_mac, host_mac) = + crate::otg::network::resolved_mac_pair(&staged_config.otg_network); + staged_config.otg_network.device_mac = device_mac; + staged_config.otg_network.host_mac = host_mac; + } + staged_config.hid.validate_otg_functions()?; + staged_config.otg_network.validate()?; + + if let Err(error) = apply_otg_config(state, &old_config, &staged_config).await { + return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await); + } + + let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor { + if staged_config.hid.backend == HidBackend::Ch9329 { + match state.hid.apply_ch9329_descriptor(descriptor).await { + Ok(actual) => { + staged_config.hid.ch9329_descriptor = actual.descriptor; + true + } + Err(error) => { + return Err(rollback_after_failure( + state, + &staged_config, + &old_config, + error, + true, + ) + .await); + } + } + } else { + false + } + } else { + false + }; + + if let Err(error) = state + .config + .update(|config| { + config.hid = staged_config.hid.clone(); + config.msd = staged_config.msd.clone(); + config.otg_network = staged_config.otg_network.clone(); + config.enforce_invariants(); + }) + .await + { + return Err(rollback_after_failure( + state, + &staged_config, + &old_config, + AppError::Config(format!("Failed to persist OTG config after apply: {error}")), + descriptor_was_applied, + ) + .await); + } + + Ok(OtgConfigResponse { + hid: staged_config.hid, + msd: staged_config.msd, + network: staged_config.otg_network, + status: state.otg_service.network_status().await, + }) +} + +async fn rollback_after_failure( + state: &Arc, + failed_config: &crate::config::AppConfig, + old_config: &crate::config::AppConfig, + primary_error: AppError, + restore_descriptor: bool, +) -> AppError { + let mut rollback_errors = Vec::new(); + + if let Err(error) = apply_otg_config(state, failed_config, old_config).await { + rollback_errors.push(format!("runtime rollback failed: {error}")); + } + if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 { + if let Err(error) = state + .hid + .apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor) + .await + { + rollback_errors.push(format!("CH9329 descriptor rollback failed: {error}")); + } + } + + if rollback_errors.is_empty() { + return primary_error; + } + + let message = format!("{primary_error}; {}", rollback_errors.join("; ")); + state.otg_service.mark_degraded(message.clone()).await; + AppError::Config(message) +} diff --git a/src/web/handlers/config/otg_network.rs b/src/web/handlers/config/otg_network.rs new file mode 100644 index 00000000..d9da27dc --- /dev/null +++ b/src/web/handlers/config/otg_network.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use axum::{extract::State, Json}; + +use crate::config::OtgNetworkConfig; +use crate::error::Result; +use crate::otg::OtgNetworkStatus; +use crate::state::AppState; + +use super::otg::update_otg_config_inner; +use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate}; + +pub async fn get_otg_network_config(State(state): State>) -> Json { + Json(state.config.get().otg_network.clone()) +} + +pub async fn update_otg_network_config( + State(state): State>, + Json(request): Json, +) -> Result> { + let response = update_otg_config_inner( + &state, + OtgConfigUpdate { + network: Some(request), + ..Default::default() + }, + ) + .await?; + Ok(Json(response.network)) +} + +pub async fn get_otg_network_status(State(state): State>) -> Json { + Json(state.otg_service.network_status().await) +} diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 7aaaaa2d..fd20a16d 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -353,12 +353,20 @@ pub struct HidConfigUpdate { pub otg_udc: Option, pub otg_descriptor: Option, pub otg_profile: Option, - pub otg_endpoint_budget: Option, pub otg_functions: Option, pub otg_keyboard_leds: Option, pub mouse_absolute: Option, } +#[typeshare] +#[cfg(unix)] +#[derive(Debug, Deserialize, Default)] +pub struct OtgConfigUpdate { + pub hid: Option, + pub msd: Option, + pub network: Option, +} + impl HidConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { if let Some(baudrate) = self.ch9329_baudrate { @@ -403,9 +411,6 @@ impl HidConfigUpdate { if let Some(profile) = self.otg_profile.clone() { config.otg_profile = profile; } - if let Some(budget) = self.otg_endpoint_budget { - config.otg_endpoint_budget = budget; - } if let Some(ref functions) = self.otg_functions { functions.apply_to(&mut config.otg_functions); } @@ -418,6 +423,38 @@ impl HidConfigUpdate { } } +#[typeshare] +#[cfg(unix)] +#[derive(Debug, Deserialize)] +pub struct OtgNetworkConfigUpdate { + pub enabled: Option, + pub driver_mode: Option, + pub bridge_interface: Option, + pub host_mac: Option, + pub device_mac: Option, +} + +#[cfg(unix)] +impl OtgNetworkConfigUpdate { + pub fn apply_to(&self, config: &mut OtgNetworkConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + if let Some(driver_mode) = self.driver_mode { + config.driver_mode = driver_mode; + } + if let Some(ref interface) = self.bridge_interface { + config.bridge_interface = interface.trim().to_string(); + } + if let Some(ref mac) = self.host_mac { + config.host_mac = mac.trim().to_ascii_lowercase(); + } + if let Some(ref mac) = self.device_mac { + config.device_mac = mac.trim().to_ascii_lowercase(); + } + } +} + #[typeshare] #[cfg(unix)] #[derive(Debug, Deserialize)] diff --git a/src/web/handlers/devices.rs b/src/web/handlers/devices.rs index 742506e6..6385675c 100644 --- a/src/web/handlers/devices.rs +++ b/src/web/handlers/devices.rs @@ -17,6 +17,12 @@ pub async fn list_usb_devices() -> Json> { Json(usb_reset::list_usb_devices()) } +#[cfg(unix)] +pub async fn list_network_interfaces() -> Result>> +{ + crate::otg::bridge::list_network_interfaces().map(Json) +} + #[cfg(unix)] #[derive(Deserialize)] pub struct UsbResetRequest { diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index f39e082a..b8712dc2 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -35,7 +35,6 @@ pub struct SetupRequest { pub hid_ch9329_baudrate: Option, pub hid_otg_udc: Option, pub hid_otg_profile: Option, - pub hid_otg_endpoint_budget: Option, pub hid_otg_keyboard_leds: Option, pub msd_enabled: Option, // Extension settings @@ -123,9 +122,6 @@ pub async fn setup_init( config.hid.otg_profile = parsed; } } - if let Some(budget) = req.hid_otg_endpoint_budget { - config.hid.otg_endpoint_budget = budget; - } if let Some(enabled) = req.hid_otg_keyboard_leds { config.hid.otg_keyboard_leds = enabled; } @@ -151,7 +147,7 @@ pub async fn setup_init( { if let Err(e) = state .otg_service - .apply_config(&new_config.hid, &new_config.msd) + .apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network) .await { tracing::warn!("Failed to apply OTG config during setup: {}", e); diff --git a/src/web/routes.rs b/src/web/routes.rs index 7d9639e1..46f1d13f 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -246,6 +246,19 @@ pub fn create_router(state: Arc) -> Router { .route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/config/msd", get(handlers::config::get_msd_config)) .route("/config/msd", patch(handlers::config::update_msd_config)) + .route("/config/otg", patch(handlers::config::update_otg_config)) + .route( + "/config/otg-network", + get(handlers::config::get_otg_network_config), + ) + .route( + "/config/otg-network", + patch(handlers::config::update_otg_network_config), + ) + .route( + "/otg/network/status", + get(handlers::config::get_otg_network_status), + ) .route("/msd/status", get(handlers::msd_status)) .route("/msd/images", get(handlers::msd_images_list)) .route("/msd/images/download", post(handlers::msd_image_download)) @@ -277,6 +290,10 @@ pub fn create_router(state: Arc) -> Router { ) .route("/msd/drive/mkdir/{*path}", post(handlers::msd_drive_mkdir)) .route("/devices/usb", get(handlers::devices::list_usb_devices)) + .route( + "/devices/network", + get(handlers::devices::list_network_interfaces), + ) .route( "/devices/usb/reset", post(handlers::devices::reset_usb_device), diff --git a/web/src/api/config.ts b/web/src/api/config.ts index ae5ff4ae..511e11e0 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -10,6 +10,12 @@ import type { HidConfigUpdate, MsdConfig, MsdConfigUpdate, + NetworkInterfaceInfo, + OtgNetworkConfig, + OtgNetworkConfigUpdate, + OtgNetworkStatus, + OtgConfigUpdate, + OtgConfigResponse, AtxConfig, AtxConfigUpdate, AtxDevices, @@ -86,6 +92,28 @@ export const msdConfigApi = { }), } +export const otgNetworkApi = { + get: () => request('/config/otg-network'), + + update: (config: OtgNetworkConfigUpdate) => + request('/config/otg-network', { + method: 'PATCH', + body: JSON.stringify(config), + }), + + status: () => request('/otg/network/status'), + + interfaces: () => request('/devices/network'), +} + +export const otgConfigApi = { + update: (config: OtgConfigUpdate) => + request('/config/otg', { + method: 'PATCH', + body: JSON.stringify(config), + }), +} + export interface WolHistoryEntry { mac_address: string updated_at: number diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 68238bd8..d3d661b2 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -123,7 +123,6 @@ export const systemApi = { hid_ch9329_baudrate?: number hid_otg_udc?: string hid_otg_profile?: string - hid_otg_endpoint_budget?: string hid_otg_keyboard_leds?: boolean msd_enabled?: boolean encoder_backend?: string @@ -804,6 +803,8 @@ export { streamConfigApi, hidConfigApi, msdConfigApi, + otgConfigApi, + otgNetworkApi, atxConfigApi, audioConfigApi, extensionsApi, diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index d80ac930..b46bfaa8 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -263,7 +263,6 @@ export default { progress: 'Step {current} of {total}', ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.', otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.', - otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.', videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.', stepExtensions: 'Extensions', @@ -510,7 +509,7 @@ export default { accountSubtitle: 'Manage credentials and session policy', networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate', videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers', - hidSubtitle: 'Configure keyboard and mouse backend with USB gadget descriptors', + hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions', msdSubtitle: 'Manage Mass Storage Device image directory', atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN', environmentSubtitle: 'System runtime environment and USB device maintenance', @@ -712,10 +711,12 @@ export default { supportedFormats: 'Supported Codecs', encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.', hidSettings: 'HID Settings', - hidSettingsDesc: 'Configure keyboard and mouse control', + hidSettingsDesc: 'Configure backend, device, descriptors, and functions in order', hidBackend: 'HID Backend', serialDevice: 'Serial Device', baudRate: 'Baud Rate', + otgUdc: 'OTG Device Controller', + otgUdcDesc: 'Select the UDC used by the USB gadget, or leave blank for automatic selection', ch9329Options: 'CH9329 Options', ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip', ch9329HybridMouse: 'Linux Absolute Mouse Compatibility', @@ -728,14 +729,8 @@ export default { ch9329DescriptorReadRequired: 'Read the CH9329 descriptor successfully before saving', ch9329DescriptorWarning: 'Saving writes CH9329 parameters; changes may not show until the device is power-cycled or reconnected', ch9329StringLengthWarning: 'CH9329 strings are limited to 23 bytes', - otgHidProfile: 'OTG HID Functions', - otgHidProfileDesc: 'Select which HID functions are exposed to the host', - otgEndpointBudget: 'Max Endpoints', - otgEndpointBudgetUnlimited: 'Unlimited', - otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.', - otgEndpointUsage: 'Endpoint usage: {used} / {limit}', - otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited', - otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.', + otgHidProfile: 'OTG Functions', + otgHidProfileDesc: 'Select which USB functions are exposed to the host', otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboardDesc: 'Standard HID keyboard device', otgKeyboardLeds: 'Keyboard LED Status', @@ -748,9 +743,14 @@ export default { otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause', otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsdDesc: 'Expose USB storage to the host', - otgProfileWarning: 'Changing HID functions will reconnect the USB device', - otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.', + otgProfileWarning: 'Saving reconnects the USB device; unsupported combinations show an error and restore the previous configuration', otgFunctionMinWarning: 'Enable at least one HID function before saving', + otgNetwork: 'USB Virtual Ethernet', + otgRuntimeDegraded: 'OTG runtime is degraded', + otgNetworkDesc: 'Bridge the USB link to an existing uplink', + otgNetworkDriver: 'Host Driver Mode', + otgNetworkInterface: 'Bridge Interface', + otgNetworkNone: 'None', otgDescriptor: 'USB Device Descriptor', otgDescriptorDesc: 'Configure USB device identification', vendorId: 'Vendor ID (VID)', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index abf9f541..4e3d4140 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -263,7 +263,6 @@ export default { progress: '步骤 {current} / {total}', ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。', otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。', - otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。', videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。', videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。', stepExtensions: '扩展设置', @@ -509,7 +508,7 @@ export default { accountSubtitle: '管理登录凭据与会话策略', networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书', videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器', - hidSubtitle: '配置键盘鼠标后端与 USB Gadget 描述符', + hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能', msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录', atxSubtitle: '配置远程电源控制硬件与网络唤醒', environmentSubtitle: '系统级运行环境与 USB 设备维护', @@ -711,10 +710,12 @@ export default { supportedFormats: '支持的编码格式', encoderHint: '硬件编码器延迟和 CPU 占用比软件编码低,画质预设更好', hidSettings: 'HID 设置', - hidSettingsDesc: '配置键盘和鼠标控制', + hidSettingsDesc: '按后端、设备、描述符和功能顺序配置', hidBackend: 'HID 后端', serialDevice: '串口设备', baudRate: '波特率', + otgUdc: 'OTG 设备控制器', + otgUdcDesc: '选择用于 USB Gadget 的 UDC,留空时自动选择', ch9329Options: 'CH9329 选项', ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性', ch9329HybridMouse: 'Linux 绝对鼠标兼容模式', @@ -727,14 +728,8 @@ export default { ch9329DescriptorReadRequired: '需要先成功读取 CH9329 描述符才能保存', ch9329DescriptorWarning: '保存会写入 CH9329 参数;需要重新上电或重新插拔后才会变化', ch9329StringLengthWarning: 'CH9329 字符串最长为 23 字节', - otgHidProfile: 'OTG HID 功能', - otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', - otgEndpointBudget: '最大端点数量', - otgEndpointBudgetUnlimited: '无限制', - otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量,OTG 功能将无法使用。', - otgEndpointUsage: '当前端点占用:{used} / {limit}', - otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限', - otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。', + otgHidProfile: 'OTG 功能', + otgHidProfileDesc: '选择对目标主机暴露的 USB 功能', otgFunctionKeyboard: '键盘', otgFunctionKeyboardDesc: '标准 HID 键盘设备', otgKeyboardLeds: '键盘状态灯', @@ -747,9 +742,14 @@ export default { otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键', otgFunctionMsd: '虚拟媒体(MSD)', otgFunctionMsdDesc: '向目标主机暴露 USB 存储', - otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接', - otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。', + otgProfileWarning: '保存后 USB 设备会重新连接;若控制器不支持当前组合,将显示失败并恢复原配置', otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存', + otgNetwork: 'USB 虚拟网卡', + otgRuntimeDegraded: 'OTG 运行时处于降级状态', + otgNetworkDesc: '桥接到现有上联网卡', + otgNetworkDriver: '目标机驱动模式', + otgNetworkInterface: '桥接网卡', + otgNetworkNone: '无', otgDescriptor: 'USB 设备描述符', otgDescriptorDesc: '配置 USB 设备标识信息', vendorId: '厂商 ID (VID)', diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index c66e8931..563adf44 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -85,7 +85,6 @@ export const useAuthStore = defineStore('auth', () => { hid_ch9329_baudrate?: number hid_otg_udc?: string hid_otg_profile?: string - hid_otg_endpoint_budget?: string hid_otg_keyboard_leds?: boolean msd_enabled?: boolean encoder_backend?: string diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index a7116aa3..0cd16ee9 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -6,6 +6,7 @@ import { audioConfigApi, hidConfigApi, msdConfigApi, + otgConfigApi, rtspConfigApi, rustdeskConfigApi, streamConfigApi, @@ -24,6 +25,8 @@ import type { HidConfigUpdate, MsdConfig, MsdConfigUpdate, + OtgConfigResponse, + OtgConfigUpdate, StreamConfigResponse, StreamConfigUpdate, VideoConfig, @@ -525,6 +528,13 @@ export const useConfigStore = defineStore('config', () => { return response } + async function updateOtg(update: OtgConfigUpdate): Promise { + const response = await otgConfigApi.update(update) + hid.value = response.hid + msd.value = response.msd + return response + } + async function updateStream(update: StreamConfigUpdate) { const response = await streamConfigApi.update(update) stream.value = response @@ -642,6 +652,7 @@ export const useConfigStore = defineStore('config', () => { updateAudio, updateHid, updateMsd, + updateOtg, updateStream, updateWeb, updateAtx, diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 11df785e..a82e2717 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -40,13 +40,6 @@ export enum OtgHidProfile { Custom = "custom", } -export enum OtgEndpointBudget { - Auto = "auto", - Five = "five", - Six = "six", - Unlimited = "unlimited", -} - export interface OtgHidFunctions { keyboard: boolean; mouse_relative: boolean; @@ -67,7 +60,6 @@ export interface HidConfig { otg_udc?: string; otg_descriptor?: OtgDescriptorConfig; otg_profile?: OtgHidProfile; - otg_endpoint_budget?: OtgEndpointBudget; otg_functions?: OtgHidFunctions; otg_keyboard_leds?: boolean; ch9329_port: string; @@ -77,6 +69,22 @@ export interface HidConfig { mouse_absolute: boolean; } +export enum OtgNetworkDriverMode { + Ncm = "ncm", + Ecm = "ecm", + Rndis = "rndis", +} + +export interface OtgNetworkConfig { + enabled: boolean; + driver_mode: OtgNetworkDriverMode; + /** Empty means select the connected NetworkManager Ethernet interface. */ + bridge_interface: string; + /** Empty values are resolved from the machine identity at runtime. */ + host_mac: string; + device_mac: string; +} + export interface MsdConfig { enabled: boolean; msd_dir: string; @@ -286,6 +294,7 @@ export interface AppConfig { auth: AuthConfig; video: VideoConfig; hid: HidConfig; + otg_network: OtgNetworkConfig; msd: MsdConfig; atx: AtxConfig; audio: AudioConfig; @@ -537,7 +546,6 @@ export interface HidConfigUpdate { otg_udc?: string; otg_descriptor?: OtgDescriptorConfigUpdate; otg_profile?: OtgHidProfile; - otg_endpoint_budget?: OtgEndpointBudget; otg_functions?: OtgHidFunctionsUpdate; otg_keyboard_leds?: boolean; mouse_absolute?: boolean; @@ -548,6 +556,49 @@ export interface MsdConfigUpdate { msd_dir?: string; } +export interface NetworkInterfaceInfo { + name: string; + interface_type: string; + state: string; + connection: string; + addresses: string[]; + has_default_route: boolean; + bridge_supported: boolean; + reason?: string; +} + +export enum OtgRuntimeHealth { + Healthy = "healthy", + Applying = "applying", + Degraded = "degraded", +} + +export interface OtgNetworkStatus { + health: OtgRuntimeHealth; + error?: string; +} + +export interface OtgConfigResponse { + hid: HidConfig; + msd: MsdConfig; + network: OtgNetworkConfig; + status: OtgNetworkStatus; +} + +export interface OtgNetworkConfigUpdate { + enabled?: boolean; + driver_mode?: OtgNetworkDriverMode; + bridge_interface?: string; + host_mac?: string; + device_mac?: string; +} + +export interface OtgConfigUpdate { + hid?: HidConfigUpdate; + msd?: MsdConfigUpdate; + network?: OtgNetworkConfigUpdate; +} + export interface RtspConfigResponse { enabled: boolean; bind: string; diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 39d8bf98..62a9677f 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -10,6 +10,7 @@ import { useAuthStore } from '@/stores/auth' import { authApi, configApi, + otgNetworkApi, hidApi, streamApi, atxConfigApi, @@ -42,11 +43,12 @@ import type { AtxDriverType, ActiveLevel, AtxDevices, - OtgEndpointBudget, OtgHidProfile, OtgHidFunctions, Ch9329DescriptorConfig, Ch9329DescriptorState, + NetworkInterfaceInfo, + OtgNetworkStatus, } from '@/types/generated' import { FrpProxyType, FrpcConfigMode } from '@/types/generated' import { formatFpsLabel, toConfigFps } from '@/lib/fps' @@ -94,7 +96,6 @@ import { EyeOff, Save, Check, - HardDrive, Power, Server, Menu, @@ -125,19 +126,18 @@ const authStore = useAuthStore() const featureVisibility = useFeatureVisibility() const isWindows = computed(() => systemStore.platform?.mode === 'windows') -const msdAvailable = computed(() => systemStore.platform?.msd.available ?? systemStore.capabilities?.msd.available ?? false) const activeSection = ref('appearance') const mobileMenuOpen = ref(false) const loading = ref(false) const saved = ref(false) +const saveError = ref('') const SETTINGS_SECTION_IDS = [ 'appearance', 'account', 'network', 'video', 'hid', - 'msd', 'atx', 'environment', 'ext-ttyd', @@ -150,19 +150,19 @@ const SETTINGS_SECTION_ID_SET = new Set(SETTINGS_SECTION_IDS) const navGroups = computed(() => [ { - title: t('settings.system'), + title: t('settings.software'), items: [ { id: 'appearance', label: t('settings.appearance'), icon: Sun }, { id: 'account', label: t('settings.account'), icon: User }, { id: 'network', label: t('settings.network'), icon: Globe }, + { id: 'about', label: t('settings.about'), icon: Info }, ] }, { - title: t('settings.hardware'), + title: t('settings.system'), items: [ - { id: 'video', label: t('settings.video'), icon: Monitor, status: config.value.video_device ? t('settings.configured') : null }, - { id: 'hid', label: t('settings.hid'), icon: Keyboard, status: config.value.hid_backend.toUpperCase() }, - ...(msdAvailable.value ? [{ id: 'msd', label: t('settings.msd'), icon: HardDrive }] : []), + { id: 'video', label: t('settings.video'), icon: Monitor }, + { id: 'hid', label: t('settings.hid'), icon: Keyboard }, { id: 'atx', label: t('settings.atx'), icon: Power }, { id: 'environment', label: t('settings.environment'), icon: Server }, ] @@ -174,12 +174,6 @@ const navGroups = computed(() => [ { id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare }, { id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink }, ] - }, - { - title: t('settings.other'), - items: [ - { id: 'about', label: t('settings.about'), icon: Info }, - ] } ]) @@ -225,6 +219,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null { if (value === 'access-control') return 'account' if (value === 'ext-frpc') return 'ext-remote-access' if (value === 'redfish') return 'third-party-access' + if (value === 'msd') return 'hid' if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access' return isSettingsSectionId(value) ? value : null } @@ -253,7 +248,6 @@ async function loadSectionData(section: SettingsSectionId) { ]) return case 'hid': - case 'msd': await Promise.all([ loadConfig(), loadDevices(), @@ -591,7 +585,6 @@ const config = ref({ hid_serial_baudrate: 9600, hid_otg_udc: '', hid_otg_profile: 'custom' as OtgHidProfile, - hid_otg_endpoint_budget: 'six' as OtgEndpointBudget, hid_otg_functions: { keyboard: true, mouse_relative: true, @@ -602,6 +595,9 @@ const config = ref({ hid_ch9329_hybrid_mouse: false, msd_enabled: false, msd_dir: '', + otg_network_enabled: false, + otg_network_driver: 'ncm' as 'ncm' | 'ecm' | 'rndis', + otg_network_interface: '', encoder_backend: 'auto', stun_server: '', turn_server: '', @@ -609,6 +605,20 @@ const config = ref({ turn_password: '', }) +const otgNetworkInterfaces = ref([]) +const otgNetworkInterfacesLoaded = ref(false) +const otgNetworkStatus = ref(null) + +function syncOtgNetworkInterface() { + const firstInterface = otgNetworkInterfaces.value[0]?.name || '' + const selectedInterfaceExists = otgNetworkInterfaces.value.some( + item => item.name === config.value.otg_network_interface, + ) + if (!selectedInterfaceExists) { + config.value.otg_network_interface = firstInterface + } +} + type OtgSelfCheckLevel = 'info' | 'warn' | 'error' type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped' @@ -909,78 +919,12 @@ function usbSpeedLabel(speed?: string): string { return map[speed] || `${speed} Mbps` } -function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget { - return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget -} - -function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget { - if (!budget || budget === 'auto') { - return defaultOtgEndpointBudgetForUdc(udc) - } - return budget -} - -function endpointLimitForBudget(budget: OtgEndpointBudget): number | null { - if (budget === 'unlimited') return null - return budget === 'five' ? 5 : 6 -} - const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions })) -const otgEndpointLimit = computed(() => - endpointLimitForBudget(config.value.hid_otg_endpoint_budget) -) - -const otgRequiredEndpoints = computed(() => { - if (config.value.hid_backend !== 'otg') return 0 - const functions = effectiveOtgFunctions.value - let endpoints = 0 - if (functions.keyboard) { - endpoints += 1 - if (config.value.hid_otg_keyboard_leds) endpoints += 1 - } - if (functions.mouse_relative) endpoints += 1 - if (functions.mouse_absolute) endpoints += 1 - if (functions.consumer) endpoints += 1 - if (config.value.msd_enabled) endpoints += 2 - return endpoints -}) - -const isOtgEndpointBudgetValid = computed(() => { - if (config.value.hid_backend !== 'otg') return true - const limit = otgEndpointLimit.value - return limit === null || otgRequiredEndpoints.value <= limit -}) - -const otgEndpointUsageText = computed(() => { - const limit = otgEndpointLimit.value - if (limit === null) { - return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value }) - } - return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit }) -}) - -const showOtgEndpointBudgetHint = computed(() => - config.value.hid_backend === 'otg' -) - const isKeyboardLedToggleDisabled = computed(() => config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard ) -function describeEndpointBudget(budget: OtgEndpointBudget): string { - switch (budget) { - case 'five': - return '5' - case 'six': - return '6' - case 'unlimited': - return t('settings.otgEndpointBudgetUnlimited') - default: - return '6' - } -} - const isHidFunctionSelectionValid = computed(() => { if (config.value.hid_backend !== 'otg') return true const f = config.value.hid_otg_functions @@ -1117,16 +1061,9 @@ const isCh9329DescriptorDirty = computed(() => { const isHidSettingsValid = computed(() => isHidFunctionSelectionValid.value - && isOtgEndpointBudgetValid.value && isCh9329DescriptorValid.value ) -watch(() => config.value.msd_enabled, (enabled) => { - if (!enabled && activeSection.value === 'msd') { - activeSection.value = 'hid' - } -}) - watch(bindMode, (mode) => { if (mode === 'custom' && bindAddressList.value.length === 0) { bindAddressList.value = [''] @@ -1201,8 +1138,6 @@ const selectedBackendFormats = computed(() => { return backend?.supported_formats || [] }) -const isCh9329Backend = computed(() => config.value.hid_backend === 'ch9329') - const selectedDevice = computed(() => { return devices.value.video.find(d => d.path === config.value.video_device) }) @@ -1408,6 +1343,7 @@ async function changePassword() { async function saveConfig() { loading.value = true saved.value = false + saveError.value = '' try { if (activeSection.value === 'video') { @@ -1436,6 +1372,7 @@ async function saveConfig() { ch9329_port: config.value.hid_serial_device || undefined, ch9329_baudrate: config.value.hid_serial_baudrate, ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse, + otg_udc: config.value.hid_otg_udc, } if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) { hidUpdate.ch9329_descriptor = { @@ -1455,22 +1392,23 @@ async function saveConfig() { serial_number: otgSerialNumber.value || undefined, } hidUpdate.otg_profile = 'custom' - hidUpdate.otg_endpoint_budget = config.value.hid_otg_endpoint_budget hidUpdate.otg_functions = { ...config.value.hid_otg_functions } hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds } - await configStore.updateHid(hidUpdate) - if (config.value.hid_backend === 'otg') { - await configStore.updateMsd({ enabled: config.value.msd_enabled }) - } else { - await configStore.updateMsd({ enabled: false }) - } - } - - if (activeSection.value === 'msd') { - await configStore.updateMsd({ - msd_dir: config.value.msd_dir || undefined, + const otgEnabled = config.value.hid_backend === 'otg' + const response = await configStore.updateOtg({ + hid: hidUpdate, + msd: { + enabled: otgEnabled && config.value.msd_enabled, + msd_dir: config.value.msd_dir || undefined, + }, + network: { + enabled: otgEnabled && config.value.otg_network_enabled, + driver_mode: config.value.otg_network_driver as any, + bridge_interface: config.value.otg_network_interface, + }, }) + otgNetworkStatus.value = response.status } if (activeSection.value !== 'hid') { @@ -1478,7 +1416,12 @@ async function saveConfig() { } saved.value = true setTimeout(() => (saved.value = false), 2000) - } catch { + } catch (error) { + saveError.value = error instanceof Error ? error.message : t('api.operationFailedDesc') + if (activeSection.value === 'hid') { + await loadConfig().catch(() => undefined) + otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null) + } } finally { loading.value = false } @@ -1486,11 +1429,18 @@ async function saveConfig() { async function loadConfig() { try { - const [video, stream, hid, msd] = await Promise.all([ + const [video, stream, hid, msd, otgNetwork] = await Promise.all([ configStore.refreshVideo(), configStore.refreshStream(), configStore.refreshHid(), configStore.refreshMsd(), + otgNetworkApi.get().catch(() => ({ + enabled: false, + driver_mode: 'ncm' as const, + bridge_interface: '', + host_mac: '', + device_mac: '', + })), ]) config.value = { @@ -1504,7 +1454,6 @@ async function loadConfig() { hid_serial_baudrate: hid.ch9329_baudrate || 9600, hid_otg_udc: hid.otg_udc || '', hid_otg_profile: 'custom' as OtgHidProfile, - hid_otg_endpoint_budget: normalizeOtgEndpointBudget(hid.otg_endpoint_budget, hid.otg_udc || ''), hid_otg_functions: { keyboard: hid.otg_functions?.keyboard ?? true, mouse_relative: hid.otg_functions?.mouse_relative ?? true, @@ -1515,12 +1464,18 @@ async function loadConfig() { hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false, msd_enabled: msd.enabled || false, msd_dir: msd.msd_dir || '', + otg_network_enabled: otgNetwork.enabled, + otg_network_driver: otgNetwork.driver_mode, + otg_network_interface: otgNetwork.bridge_interface, encoder_backend: stream.encoder || 'auto', stun_server: stream.stun_server || '', turn_server: stream.turn_server || '', turn_username: stream.turn_username || '', turn_password: stream.turn_password || '', } + if (otgNetworkInterfacesLoaded.value) { + syncOtgNetworkInterface() + } if (hid.otg_descriptor) { otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b' @@ -1539,14 +1494,21 @@ async function loadConfig() { } else { clearCh9329DescriptorState() } - + otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null) } catch { } } async function loadDevices() { try { - devices.value = await configApi.listDevices() + const [deviceConfig, networkInterfaces] = await Promise.all([ + configApi.listDevices(), + otgNetworkApi.interfaces().catch(() => []), + ]) + devices.value = deviceConfig + otgNetworkInterfaces.value = networkInterfaces + otgNetworkInterfacesLoaded.value = true + syncOtgNetworkInterface() } catch { } } @@ -2701,7 +2663,6 @@ watch(isWindows, () => { > {{ item.label }} - {{ item.status }} @@ -2738,7 +2699,6 @@ watch(isWindows, () => { > {{ item.label }} - {{ item.status }} @@ -3080,24 +3040,16 @@ watch(isWindows, () => { +
+ + +

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

+
+ @@ -3918,42 +3919,6 @@ watch(isWindows, () => { - -
- - - {{ t('settings.msdSettings') }} - {{ t('settings.msdDesc') }} - - -
-

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

-

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

-
-
-
- - -

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

-
-

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

-
- -
-
-

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

-

- {{ config.msd_enabled ? t('settings.willBeEnabledAfterSave') : t('settings.disabled') }} -

-
- - {{ config.msd_enabled ? t('common.enabled') : t('common.disabled') }} - -
-
-
-
-
@@ -5311,7 +5276,7 @@ watch(isWindows, () => {
-
+

@@ -5325,6 +5290,7 @@ watch(isWindows, () => { {{ t('settings.ch9329DescriptorLoading') }}

+

{{ saveError }}