mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 17:31:45 +08:00
feat: 初步支持 USB 网卡桥接;删除 OTG 端点预算管理
This commit is contained in:
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
85
src/config/schema/otg_network.rs
Normal file
85
src/config/schema/otg_network.rs
Normal file
@@ -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::<Result<Vec<_>, _>>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
945
src/otg/bridge.rs
Normal file
945
src/otg/bridge.rs
Normal file
@@ -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<String>,
|
||||
pub has_default_route: bool,
|
||||
pub bridge_supported: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct NetworkManagerDevice {
|
||||
name: String,
|
||||
interface_type: String,
|
||||
state: String,
|
||||
connection: String,
|
||||
addresses: Vec<String>,
|
||||
has_default_route: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BridgeJournal {
|
||||
version: u8,
|
||||
uplink: String,
|
||||
existing_bridge: bool,
|
||||
original_connection_uuid: Option<String>,
|
||||
bridge_profile_uuid: Option<String>,
|
||||
uplink_profile_uuid: Option<String>,
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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::<BridgeJournal>(&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<Vec<NetworkInterfaceInfo>> {
|
||||
let devices = enumerate_network_manager_devices()?;
|
||||
Ok(bridge_candidates(devices, is_physical_network_interface))
|
||||
}
|
||||
|
||||
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
|
||||
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<NetworkManagerDevice> {
|
||||
let mut devices = Vec::new();
|
||||
for line in text.lines() {
|
||||
let fields = line.splitn(4, ':').collect::<Vec<_>>();
|
||||
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<NetworkManagerDevice>,
|
||||
is_physical: impl Fn(&str) -> bool,
|
||||
) -> Vec<NetworkInterfaceInfo> {
|
||||
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<String> {
|
||||
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<Vec<String>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
ipv4_addresses(interface).into_iter().next()
|
||||
}
|
||||
|
||||
fn ipv4_addresses(interface: &str) -> Vec<String> {
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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<Output> {
|
||||
run_command("nmcli", args)
|
||||
}
|
||||
|
||||
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
|
||||
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::<Vec<_>>(),
|
||||
["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"));
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,6 @@ fn collect_dir_names(path: &Path, devices: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<()>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Box<dyn GadgetFunction>>,
|
||||
bound_udc: Option<String>,
|
||||
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<NetworkFunction> {
|
||||
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<dyn GadgetFunction>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
196
src/otg/network.rs
Normal file
196
src/otg/network.rs
Normal file
@@ -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<Self> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<OtgSelfCheckItem>,
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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(
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl HidDevicePaths {
|
||||
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
||||
[
|
||||
@@ -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<Self> {
|
||||
pub(crate) fn from_config(
|
||||
hid: &HidConfig,
|
||||
msd: &MsdConfig,
|
||||
network: &OtgNetworkConfig,
|
||||
) -> Result<Self> {
|
||||
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<String>,
|
||||
pub hid_paths: Option<HidDevicePaths>,
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
pub keyboard_leds_enabled: bool,
|
||||
pub max_endpoints: u8,
|
||||
pub descriptor: Option<GadgetDescriptor>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -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<Option<OtgGadgetManager>>,
|
||||
state: RwLock<OtgServiceState>,
|
||||
msd_function: RwLock<Option<MsdFunction>>,
|
||||
network_bridge: Mutex<Option<NetworkBridgeRuntime>>,
|
||||
desired: RwLock<OtgDesiredState>,
|
||||
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<OtgDesiredState> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppState>) -> Result<()> {
|
||||
async fn reconcile_otg_config(
|
||||
state: &Arc<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
_old_config: &AtxConfig,
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<HidConfig> {
|
||||
Json(state.config.get().hid.clone())
|
||||
@@ -16,54 +16,13 @@ pub async fn update_hid_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<HidConfigUpdate>,
|
||||
) -> Result<Json<HidConfig>> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<MsdConfig> {
|
||||
Json(state.config.get().msd.clone())
|
||||
@@ -16,28 +16,13 @@ pub async fn update_msd_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<MsdConfigUpdate>,
|
||||
) -> Result<Json<MsdConfig>> {
|
||||
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))
|
||||
}
|
||||
|
||||
166
src/web/handlers/config/otg.rs
Normal file
166
src/web/handlers/config/otg.rs
Normal file
@@ -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<Arc<AppState>>,
|
||||
Json(request): Json<OtgConfigUpdate>,
|
||||
) -> Result<Json<OtgConfigResponse>> {
|
||||
update_otg_config_inner(&state, request).await.map(Json)
|
||||
}
|
||||
|
||||
pub(super) async fn update_otg_config_inner(
|
||||
state: &Arc<AppState>,
|
||||
request: OtgConfigUpdate,
|
||||
) -> Result<OtgConfigResponse> {
|
||||
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<AppState>,
|
||||
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)
|
||||
}
|
||||
34
src/web/handlers/config/otg_network.rs
Normal file
34
src/web/handlers/config/otg_network.rs
Normal file
@@ -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<Arc<AppState>>) -> Json<OtgNetworkConfig> {
|
||||
Json(state.config.get().otg_network.clone())
|
||||
}
|
||||
|
||||
pub async fn update_otg_network_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<OtgNetworkConfigUpdate>,
|
||||
) -> Result<Json<OtgNetworkConfig>> {
|
||||
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<Arc<AppState>>) -> Json<OtgNetworkStatus> {
|
||||
Json(state.otg_service.network_status().await)
|
||||
}
|
||||
@@ -353,12 +353,20 @@ pub struct HidConfigUpdate {
|
||||
pub otg_udc: Option<String>,
|
||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||
pub otg_profile: Option<OtgHidProfile>,
|
||||
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
|
||||
pub otg_functions: Option<OtgHidFunctionsUpdate>,
|
||||
pub otg_keyboard_leds: Option<bool>,
|
||||
pub mouse_absolute: Option<bool>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct OtgConfigUpdate {
|
||||
pub hid: Option<HidConfigUpdate>,
|
||||
pub msd: Option<MsdConfigUpdate>,
|
||||
pub network: Option<OtgNetworkConfigUpdate>,
|
||||
}
|
||||
|
||||
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<bool>,
|
||||
pub driver_mode: Option<OtgNetworkDriverMode>,
|
||||
pub bridge_interface: Option<String>,
|
||||
pub host_mac: Option<String>,
|
||||
pub device_mac: Option<String>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
||||
@@ -17,6 +17,12 @@ pub async fn list_usb_devices() -> Json<Vec<usb_reset::UsbDeviceInfo>> {
|
||||
Json(usb_reset::list_usb_devices())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn list_network_interfaces() -> Result<Json<Vec<crate::otg::bridge::NetworkInterfaceInfo>>>
|
||||
{
|
||||
crate::otg::bridge::list_network_interfaces().map(Json)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsbResetRequest {
|
||||
|
||||
@@ -35,7 +35,6 @@ pub struct SetupRequest {
|
||||
pub hid_ch9329_baudrate: Option<u32>,
|
||||
pub hid_otg_udc: Option<String>,
|
||||
pub hid_otg_profile: Option<String>,
|
||||
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
|
||||
pub hid_otg_keyboard_leds: Option<bool>,
|
||||
pub msd_enabled: Option<bool>,
|
||||
// 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);
|
||||
|
||||
@@ -246,6 +246,19 @@ pub fn create_router(state: Arc<AppState>) -> 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<AppState>) -> 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),
|
||||
|
||||
Reference in New Issue
Block a user