mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-27 16:00:49 +08:00
fix(otg): 适配 OpenWrt netifd 网络桥接与事务恢复
This commit is contained in:
@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::netifd_bridge;
|
||||||
|
use super::network_backend::{current_backend, NetworkBackend};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
const BRIDGE_IF: &str = "okvm-br0";
|
const BRIDGE_IF: &str = "okvm-br0";
|
||||||
@@ -104,11 +106,24 @@ impl TransactionProfiles {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NetworkBridgeRuntime {
|
pub struct NetworkBridgeRuntime {
|
||||||
journal: BridgeJournal,
|
journal: RuntimeJournal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum RuntimeJournal {
|
||||||
|
NetworkManager(BridgeJournal),
|
||||||
|
Netifd(netifd_bridge::Journal),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NetworkBridgeRuntime {
|
impl NetworkBridgeRuntime {
|
||||||
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
|
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
|
||||||
|
if current_backend().map_err(AppError::Config)? == NetworkBackend::Netifd {
|
||||||
|
let interfaces = list_netifd_interfaces()?;
|
||||||
|
let selected = select_bridge_candidate(&interfaces, requested)?;
|
||||||
|
return netifd_bridge::activate(selected, usb_interface).map(|journal| Self {
|
||||||
|
journal: RuntimeJournal::Netifd(journal),
|
||||||
|
});
|
||||||
|
}
|
||||||
ensure_command("nmcli")?;
|
ensure_command("nmcli")?;
|
||||||
ensure_command("ip")?;
|
ensure_command("ip")?;
|
||||||
|
|
||||||
@@ -321,16 +336,23 @@ impl NetworkBridgeRuntime {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_address) => Ok(Self { journal }),
|
Ok(_address) => Ok(Self {
|
||||||
|
journal: RuntimeJournal::NetworkManager(journal),
|
||||||
|
}),
|
||||||
Err(error) => Err(restore_or_combine(&journal, error)),
|
Err(error) => Err(restore_or_combine(&journal, error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deactivate(&self) -> Result<()> {
|
pub fn deactivate(&self) -> Result<()> {
|
||||||
restore_from_journal(&self.journal)
|
// Use the backend recorded at activation, even if the environment changes.
|
||||||
|
match &self.journal {
|
||||||
|
RuntimeJournal::NetworkManager(journal) => restore_from_journal(journal),
|
||||||
|
RuntimeJournal::Netifd(journal) => netifd_bridge::deactivate(journal),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recover_stale_transaction() -> Result<()> {
|
pub fn recover_stale_transaction() -> Result<()> {
|
||||||
|
netifd_bridge::recover()?;
|
||||||
let value = match fs::read_to_string(JOURNAL_PATH) {
|
let value = match fs::read_to_string(JOURNAL_PATH) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
@@ -340,6 +362,7 @@ impl NetworkBridgeRuntime {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
ensure_command("nmcli")?;
|
||||||
match serde_json::from_str::<BridgeJournal>(&value) {
|
match serde_json::from_str::<BridgeJournal>(&value) {
|
||||||
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
|
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
|
||||||
Ok(journal) => Err(AppError::Config(format!(
|
Ok(journal) => Err(AppError::Config(format!(
|
||||||
@@ -354,10 +377,92 @@ impl NetworkBridgeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
|
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
|
||||||
|
if current_backend().map_err(AppError::Config)? == NetworkBackend::Netifd {
|
||||||
|
return list_netifd_interfaces();
|
||||||
|
}
|
||||||
let devices = enumerate_network_manager_devices()?;
|
let devices = enumerate_network_manager_devices()?;
|
||||||
Ok(bridge_candidates(devices, is_physical_network_interface))
|
Ok(bridge_candidates(devices, is_physical_network_interface))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_netifd_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
|
||||||
|
let output = run_command("ubus", &["call", "network.interface", "dump"])?;
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||||
|
.map_err(|error| AppError::Internal(format!("Invalid netifd interface dump: {error}")))?;
|
||||||
|
parse_netifd_interfaces(&value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_netifd_interfaces(value: &serde_json::Value) -> Result<Vec<NetworkInterfaceInfo>> {
|
||||||
|
parse_netifd_interfaces_with_check(value, netifd_bridge::bridgeable_device)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_netifd_interfaces_with_check(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
bridgeable: impl Fn(&str) -> bool,
|
||||||
|
) -> Result<Vec<NetworkInterfaceInfo>> {
|
||||||
|
let interfaces = value
|
||||||
|
.get("interface")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.ok_or_else(|| AppError::Internal("netifd dump is missing interface array".to_string()))?;
|
||||||
|
Ok(interfaces
|
||||||
|
.iter()
|
||||||
|
.filter_map(|interface| {
|
||||||
|
let logical_name = interface.get("interface")?.as_str()?;
|
||||||
|
let name = interface
|
||||||
|
.get("l3_device")
|
||||||
|
.or_else(|| interface.get("device"))?
|
||||||
|
.as_str()?;
|
||||||
|
if name == "lo" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let up = interface
|
||||||
|
.get("up")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let addresses: Vec<String> = interface
|
||||||
|
.get("ipv4-address")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|address| {
|
||||||
|
let ip = address.get("address")?.as_str()?;
|
||||||
|
let mask = address.get("mask")?.as_u64()?;
|
||||||
|
Some(format!("{ip}/{mask}"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let has_default_route = interface
|
||||||
|
.get("route")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|route| {
|
||||||
|
route.get("target").and_then(serde_json::Value::as_str) == Some("0.0.0.0")
|
||||||
|
&& route.get("mask").and_then(serde_json::Value::as_u64) == Some(0)
|
||||||
|
});
|
||||||
|
let reason = if !up {
|
||||||
|
Some("Network interface is disconnected".to_string())
|
||||||
|
} else if !matches!(interface.get("proto").and_then(serde_json::Value::as_str), Some("dhcp" | "static")) {
|
||||||
|
Some("OTG bridging supports DHCP and static Ethernet uplinks".to_string())
|
||||||
|
} else if !bridgeable(name) {
|
||||||
|
Some("OTG uplink must be Ethernet or an existing bridge; ordinary Wi-Fi clients cannot be bridged".to_string())
|
||||||
|
} else if addresses.is_empty() {
|
||||||
|
Some("OTG bridging requires an IPv4 uplink".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Some(NetworkInterfaceInfo {
|
||||||
|
name: name.to_string(),
|
||||||
|
interface_type: "netifd".to_string(),
|
||||||
|
state: if up { "connected" } else { "disconnected" }.to_string(),
|
||||||
|
connection: logical_name.to_string(),
|
||||||
|
addresses,
|
||||||
|
has_default_route,
|
||||||
|
bridge_supported: reason.is_none(),
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
|
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
|
||||||
ensure_command("nmcli")?;
|
ensure_command("nmcli")?;
|
||||||
let output = run_command(
|
let output = run_command(
|
||||||
@@ -440,10 +545,11 @@ fn select_bridge_candidate<'a>(
|
|||||||
if requested.trim().is_empty() {
|
if requested.trim().is_empty() {
|
||||||
return interfaces
|
return interfaces
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|item| item.bridge_supported)
|
||||||
.max_by_key(|item| item.has_default_route)
|
.max_by_key(|item| item.has_default_route)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::Config(
|
AppError::Config(
|
||||||
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
|
"No connected Ethernet interface or supported bridge is available for OTG bridging"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@@ -451,10 +557,15 @@ fn select_bridge_candidate<'a>(
|
|||||||
|
|
||||||
interfaces
|
interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.find(|item| item.name == requested)
|
.find(|item| item.name == requested && item.bridge_supported)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::Config(format!(
|
AppError::Config(format!(
|
||||||
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
|
"Network interface {requested} does not support OTG bridging: {}",
|
||||||
|
interfaces
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.name == requested)
|
||||||
|
.and_then(|item| item.reason.as_deref())
|
||||||
|
.unwrap_or("interface unavailable")
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -759,7 +870,7 @@ fn connection_value(connection: &str, property: &str) -> Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_ipv4_address(interface: &str) -> Option<String> {
|
pub(super) fn first_ipv4_address(interface: &str) -> Option<String> {
|
||||||
ipv4_addresses(interface).into_iter().next()
|
ipv4_addresses(interface).into_iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +894,7 @@ fn ipv4_addresses(interface: &str) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_route(interface: &str) -> Option<String> {
|
pub(super) fn default_route(interface: &str) -> Option<String> {
|
||||||
let output = Command::new("ip")
|
let output = Command::new("ip")
|
||||||
.args(["-4", "route", "show", "default", "dev", interface])
|
.args(["-4", "route", "show", "default", "dev", interface])
|
||||||
.output()
|
.output()
|
||||||
@@ -801,7 +912,7 @@ fn gateway_from_route(route: &str) -> Option<&str> {
|
|||||||
.find_map(|part| (part[0] == "via").then_some(part[1]))
|
.find_map(|part| (part[0] == "via").then_some(part[1]))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_command(name: &str) -> Result<()> {
|
pub(super) fn ensure_command(name: &str) -> Result<()> {
|
||||||
let status = Command::new(name).arg("--version").output();
|
let status = Command::new(name).arg("--version").output();
|
||||||
if status.is_err() {
|
if status.is_err() {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
@@ -815,7 +926,7 @@ fn run_nmcli(args: &[&str]) -> Result<Output> {
|
|||||||
run_command("nmcli", args)
|
run_command("nmcli", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
|
pub(super) fn run_command(command: &str, args: &[&str]) -> Result<Output> {
|
||||||
let output = Command::new(command)
|
let output = Command::new(command)
|
||||||
.env("LC_ALL", "C")
|
.env("LC_ALL", "C")
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -842,6 +953,49 @@ fn run_command(command: &str, args: &[&str]) -> Result<Output> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn netifd_dump_preserves_logical_name_addresses_and_default_route() {
|
||||||
|
let dump = serde_json::json!({"interface": [
|
||||||
|
{"interface": "loopback", "l3_device": "lo", "up": true},
|
||||||
|
{"interface": "lan", "l3_device": "br-lan", "up": true, "proto": "dhcp",
|
||||||
|
"ipv4-address": [{"address": "192.0.2.5", "mask": 24}],
|
||||||
|
"route": [{"target": "0.0.0.0", "mask": 0}]},
|
||||||
|
{"interface": "offline", "device": "eth1", "up": false}
|
||||||
|
]});
|
||||||
|
let result = parse_netifd_interfaces_with_check(&dump, |_| true).unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0].name, "br-lan");
|
||||||
|
assert_eq!(result[0].connection, "lan");
|
||||||
|
assert_eq!(result[0].addresses, vec!["192.0.2.5/24"]);
|
||||||
|
assert!(result[0].has_default_route);
|
||||||
|
assert!(result[0].bridge_supported);
|
||||||
|
assert!(result[0].reason.is_none());
|
||||||
|
assert_eq!(result[1].state, "disconnected");
|
||||||
|
assert!(!result[1].has_default_route);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_netifd_dump_fails_and_incomplete_entries_are_skipped() {
|
||||||
|
assert!(parse_netifd_interfaces(&serde_json::json!({})).is_err());
|
||||||
|
assert!(parse_netifd_interfaces(&serde_json::json!({"interface": {}})).is_err());
|
||||||
|
let dump = serde_json::json!({"interface": [{"interface": "pending"}, {}]});
|
||||||
|
assert!(parse_netifd_interfaces(&dump).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn netifd_selection_skips_wifi_ppp_and_ipv6_only_uplinks() {
|
||||||
|
let dump = serde_json::json!({"interface": [
|
||||||
|
{"interface":"wifi", "l3_device":"wlan0", "up":true, "proto":"dhcp", "ipv4-address":[{"address":"192.0.2.6","mask":24}], "route":[{"target":"0.0.0.0","mask":0}]},
|
||||||
|
{"interface":"wan", "l3_device":"pppoe-wan", "up":true, "proto":"pppoe"},
|
||||||
|
{"interface":"lan6", "l3_device":"eth1", "up":true, "proto":"static"},
|
||||||
|
{"interface":"lan", "l3_device":"eth0", "up":true, "proto":"dhcp", "ipv4-address":[{"address":"192.0.2.5","mask":24}]}
|
||||||
|
]});
|
||||||
|
let interfaces = parse_netifd_interfaces_with_check(&dump, |name| name.starts_with("eth")).unwrap();
|
||||||
|
assert_eq!(select_bridge_candidate(&interfaces, "").unwrap().name, "eth0");
|
||||||
|
assert!(select_bridge_candidate(&interfaces, "wlan0").is_err());
|
||||||
|
assert!(select_bridge_candidate(&interfaces, "eth1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
fn device(
|
fn device(
|
||||||
name: &str,
|
name: &str,
|
||||||
interface_type: &str,
|
interface_type: &str,
|
||||||
@@ -994,6 +1148,6 @@ mod tests {
|
|||||||
let error = select_bridge_candidate(&[], "").unwrap_err();
|
let error = select_bridge_candidate(&[], "").unwrap_err();
|
||||||
|
|
||||||
assert!(matches!(error, AppError::Config(_)));
|
assert!(matches!(error, AppError::Config(_)));
|
||||||
assert!(error.to_string().contains("connected physical"));
|
assert!(error.to_string().contains("connected Ethernet"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub mod manager;
|
|||||||
pub mod msd;
|
pub mod msd;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod network_backend;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod netifd_bridge;
|
||||||
pub mod report_desc;
|
pub mod report_desc;
|
||||||
pub mod self_check;
|
pub mod self_check;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
826
src/otg/netifd_bridge.rs
Normal file
826
src/otg/netifd_bridge.rs
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
//! OpenWrt bridge transactions. Preserve logical interfaces and their L3 settings.
|
||||||
|
//! The recovery journal lives in /etc so committed UCI changes survive a reboot safely.
|
||||||
|
use super::bridge::{
|
||||||
|
default_route, ensure_command, first_ipv4_address, run_command, NetworkInterfaceInfo,
|
||||||
|
};
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fs,
|
||||||
|
io::Write,
|
||||||
|
os::unix::fs::PermissionsExt,
|
||||||
|
path::Path,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const JOURNAL_PATH: &str = "/etc/one-kvm/otg-netifd-bridge.json";
|
||||||
|
const BRIDGE: &str = "okvm-br0";
|
||||||
|
const VERSION: u8 = 1;
|
||||||
|
type Config = BTreeMap<String, Map<String, Value>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(super) struct Journal {
|
||||||
|
version: u8,
|
||||||
|
uplink: String,
|
||||||
|
usb: String,
|
||||||
|
bridge: String,
|
||||||
|
had_default_route: bool,
|
||||||
|
interface_devices: BTreeMap<String, String>,
|
||||||
|
created_section: Option<String>,
|
||||||
|
bridge_section: String,
|
||||||
|
// Exact owned section options; unrelated user edits are preserved on recovery.
|
||||||
|
created_options: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Edit {
|
||||||
|
Set(String, String),
|
||||||
|
Delete(String),
|
||||||
|
AddList(String, String),
|
||||||
|
DelList(String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(message: impl Into<String>) -> AppError {
|
||||||
|
AppError::Config(message.into())
|
||||||
|
}
|
||||||
|
fn io_error(error: impl std::fmt::Display) -> AppError {
|
||||||
|
AppError::Internal(format!("OpenWrt bridge: {error}"))
|
||||||
|
}
|
||||||
|
fn identifier(name: &str) -> bool {
|
||||||
|
!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||||
|
}
|
||||||
|
fn device_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.len() < 16
|
||||||
|
&& name
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
|
||||||
|
}
|
||||||
|
fn option<'a>(section: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||||
|
section.get(key)?.as_str()
|
||||||
|
}
|
||||||
|
fn ports(section: &Map<String, Value>) -> Result<Vec<String>> {
|
||||||
|
match section.get("ports") {
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
Some(Value::Array(values)) => values
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
|
v.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| invalid("Invalid UCI bridge port list"))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Some(Value::String(value)) => Ok(value.split_whitespace().map(str::to_owned).collect()),
|
||||||
|
_ => Err(invalid("Invalid UCI bridge ports")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn bridgeable_device(name: &str) -> bool {
|
||||||
|
if !device_name(name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let path = Path::new("/sys/class/net").join(name);
|
||||||
|
path.join("bridge").is_dir()
|
||||||
|
|| (path.join("device").exists()
|
||||||
|
&& fs::read_to_string(path.join("type")).is_ok_and(|value| value.trim() == "1")
|
||||||
|
&& !path.join("wireless").exists()
|
||||||
|
&& !path.join("phy80211").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan(
|
||||||
|
config: &Config,
|
||||||
|
selected: &NetworkInterfaceInfo,
|
||||||
|
usb: &str,
|
||||||
|
mac: &str,
|
||||||
|
existing_bridge: bool,
|
||||||
|
) -> Result<(Journal, Vec<Edit>)> {
|
||||||
|
if !device_name(&selected.name)
|
||||||
|
|| !device_name(usb)
|
||||||
|
|| selected.name == usb
|
||||||
|
|| !identifier(&selected.connection)
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"Invalid or identical OpenWrt uplink and USB interfaces",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let interface = config
|
||||||
|
.get(&selected.connection)
|
||||||
|
.ok_or_else(|| invalid("Selected UCI interface no longer exists"))?;
|
||||||
|
if option(interface, ".type") != Some("interface")
|
||||||
|
|| option(interface, "device") != Some(selected.name.as_str())
|
||||||
|
|| interface.contains_key("ifname")
|
||||||
|
|| interface.contains_key("type")
|
||||||
|
{
|
||||||
|
return Err(invalid("OTG bridging requires a current UCI interface with a device option; legacy ifname/type configurations are unsupported"));
|
||||||
|
}
|
||||||
|
if !matches!(option(interface, "proto"), Some("dhcp" | "static")) {
|
||||||
|
return Err(invalid(
|
||||||
|
"OTG bridging supports DHCP and static Ethernet uplinks",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for section in config.values() {
|
||||||
|
if option(section, ".type") == Some("interface")
|
||||||
|
&& option(section, "ifname")
|
||||||
|
.is_some_and(|value| value.split_whitespace().any(|name| name == selected.name))
|
||||||
|
{
|
||||||
|
return Err(invalid("Another UCI interface uses the uplink through legacy ifname; migrate it to a device option first"));
|
||||||
|
}
|
||||||
|
if option(section, "device") == Some(usb)
|
||||||
|
|| option(section, "ifname").is_some_and(|v| v.split_whitespace().any(|p| p == usb))
|
||||||
|
|| (option(section, ".type") == Some("device")
|
||||||
|
&& ports(section)?.iter().any(|p| p == usb))
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"USB interface already belongs to an OpenWrt network configuration",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut journal = Journal {
|
||||||
|
version: VERSION,
|
||||||
|
uplink: selected.name.clone(),
|
||||||
|
usb: usb.to_owned(),
|
||||||
|
bridge: selected.name.clone(),
|
||||||
|
had_default_route: selected.has_default_route,
|
||||||
|
interface_devices: BTreeMap::new(),
|
||||||
|
created_section: None,
|
||||||
|
bridge_section: String::new(),
|
||||||
|
created_options: Map::new(),
|
||||||
|
};
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
if existing_bridge {
|
||||||
|
let matches: Vec<_> = config
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| {
|
||||||
|
option(s, ".type") == Some("device")
|
||||||
|
&& option(s, "name") == Some(selected.name.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if matches.len() != 1 || option(matches[0].1, "type") != Some("bridge") {
|
||||||
|
return Err(invalid("Existing bridge must have one UCI device section"));
|
||||||
|
}
|
||||||
|
let (name, section) = matches[0];
|
||||||
|
if !identifier(name)
|
||||||
|
|| matches!(
|
||||||
|
option(section, "vlan_filtering"),
|
||||||
|
Some("1" | "true" | "yes" | "on")
|
||||||
|
)
|
||||||
|
|| config.values().any(|s| {
|
||||||
|
option(s, ".type") == Some("bridge-vlan")
|
||||||
|
&& option(s, "device") == Some(selected.name.as_str())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(invalid("VLAN-filtered bridges require an explicit USB VLAN configuration and are unsupported"));
|
||||||
|
}
|
||||||
|
// Preserve list semantics; a scalar ports option is not safe to edit with add_list.
|
||||||
|
if section.get("ports").is_some_and(|v| !v.is_array()) {
|
||||||
|
return Err(invalid("Bridge ports must use UCI list entries"));
|
||||||
|
}
|
||||||
|
journal.bridge_section = name.clone();
|
||||||
|
edits.push(Edit::AddList(format!("{name}.ports"), usb.to_owned()));
|
||||||
|
} else {
|
||||||
|
if config.values().any(|s| option(s, "name") == Some(BRIDGE)) {
|
||||||
|
return Err(invalid(
|
||||||
|
"okvm-br0 already exists; refusing to replace another bridge",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.values().any(|s| {
|
||||||
|
option(s, ".type") == Some("device")
|
||||||
|
&& ports(s).is_ok_and(|p| p.iter().any(|p| p == &selected.name))
|
||||||
|
}) {
|
||||||
|
return Err(invalid(
|
||||||
|
"Selected uplink already belongs to a configured bridge",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let section = format!("one_kvm_otg_{}", Uuid::new_v4().simple());
|
||||||
|
journal.bridge = BRIDGE.to_owned();
|
||||||
|
journal.bridge_section = section.clone();
|
||||||
|
journal.created_section = Some(section.clone());
|
||||||
|
journal.created_options = json!({".type":"device", "name":BRIDGE, "type":"bridge", "stp":"0", "macaddr":mac, "ports":[selected.name, usb]}).as_object().unwrap().clone();
|
||||||
|
edits.push(Edit::Set(section.clone(), "device".to_owned()));
|
||||||
|
for key in ["name", "type", "stp", "macaddr"] {
|
||||||
|
edits.push(Edit::Set(
|
||||||
|
format!("{section}.{key}"),
|
||||||
|
option(&journal.created_options, key).unwrap().to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for port in [selected.name.as_str(), usb] {
|
||||||
|
edits.push(Edit::AddList(format!("{section}.ports"), port.to_owned()));
|
||||||
|
}
|
||||||
|
for (name, section) in config {
|
||||||
|
if option(section, ".type") != Some("interface")
|
||||||
|
|| option(section, "device") != Some(selected.name.as_str())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !identifier(name)
|
||||||
|
|| section.contains_key("ifname")
|
||||||
|
|| section.contains_key("type")
|
||||||
|
|| !matches!(
|
||||||
|
option(section, "proto"),
|
||||||
|
Some("dhcp" | "static" | "dhcpv6" | "none")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(invalid("An additional uplink interface has an unsupported protocol or legacy configuration"));
|
||||||
|
}
|
||||||
|
journal
|
||||||
|
.interface_devices
|
||||||
|
.insert(name.clone(), selected.name.clone());
|
||||||
|
edits.push(Edit::Set(format!("{name}.device"), BRIDGE.to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((journal, edits))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_plan(config: &Config, journal: &Journal) -> Result<Vec<Edit>> {
|
||||||
|
if journal.version != VERSION
|
||||||
|
|| !identifier(&journal.bridge_section)
|
||||||
|
|| !device_name(&journal.uplink)
|
||||||
|
|| !device_name(&journal.usb)
|
||||||
|
|| !device_name(&journal.bridge)
|
||||||
|
{
|
||||||
|
return Err(invalid("Invalid OpenWrt bridge recovery journal"));
|
||||||
|
}
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
for (name, original) in &journal.interface_devices {
|
||||||
|
if !identifier(name) {
|
||||||
|
return Err(invalid("Invalid recovery interface name"));
|
||||||
|
}
|
||||||
|
let section = config.get(name).ok_or_else(|| {
|
||||||
|
invalid(format!(
|
||||||
|
"Recovery interface {name} was removed; journal retained"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
match option(section, "device") {
|
||||||
|
Some(value) if value == original => {},
|
||||||
|
Some(value) if value == journal.bridge => edits.push(Edit::Set(format!("{name}.device"), original.clone())),
|
||||||
|
_ => return Err(invalid(format!("Interface {name} was changed externally; refusing to overwrite it; journal retained"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(name) = &journal.created_section {
|
||||||
|
if name != &journal.bridge_section || !name.starts_with("one_kvm_otg_") {
|
||||||
|
return Err(invalid("Invalid owned bridge section"));
|
||||||
|
}
|
||||||
|
if let Some(section) = config.get(name) {
|
||||||
|
let actual: Map<_, _> = section
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| !key.starts_with('.') || key.as_str() == ".type")
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
if actual != journal.created_options {
|
||||||
|
return Err(invalid(
|
||||||
|
"Owned bridge was changed externally; refusing to delete it; journal retained",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.iter().any(|(name, s)| {
|
||||||
|
!journal.interface_devices.contains_key(name)
|
||||||
|
&& option(s, "device") == Some(journal.bridge.as_str())
|
||||||
|
}) {
|
||||||
|
return Err(invalid(
|
||||||
|
"Another interface now uses the owned bridge; journal retained",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
edits.push(Edit::Delete(name.clone()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let section = config
|
||||||
|
.get(&journal.bridge_section)
|
||||||
|
.ok_or_else(|| invalid("Existing bridge was removed; journal retained"))?;
|
||||||
|
if option(section, "name") != Some(journal.bridge.as_str())
|
||||||
|
|| option(section, "type") != Some("bridge")
|
||||||
|
{
|
||||||
|
return Err(invalid("Existing bridge was replaced; journal retained"));
|
||||||
|
}
|
||||||
|
if ports(section)?.contains(&journal.usb) {
|
||||||
|
edits.push(Edit::DelList(
|
||||||
|
format!("{}.ports", journal.bridge_section),
|
||||||
|
journal.usb.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(edits)
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Control {
|
||||||
|
fn config(&mut self) -> Result<Config>;
|
||||||
|
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()>;
|
||||||
|
fn reload(&mut self) -> Result<()>;
|
||||||
|
fn wait(&mut self, journal: &Journal, active: bool) -> Result<()>;
|
||||||
|
fn save(&mut self, journal: &Journal) -> Result<()>;
|
||||||
|
fn clear(&mut self) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_with(
|
||||||
|
control: &mut impl Control,
|
||||||
|
selected: &NetworkInterfaceInfo,
|
||||||
|
usb: &str,
|
||||||
|
mac: &str,
|
||||||
|
existing_bridge: bool,
|
||||||
|
) -> Result<Journal> {
|
||||||
|
let config = control.config()?;
|
||||||
|
let (journal, edits) = plan(&config, selected, usb, mac, existing_bridge)?;
|
||||||
|
control.save(&journal)?; // Durable recovery precedes every configuration change.
|
||||||
|
let result = control
|
||||||
|
.apply(&config, &edits)
|
||||||
|
.and_then(|()| control.reload())
|
||||||
|
.and_then(|()| control.wait(&journal, true));
|
||||||
|
if let Err(primary) = result {
|
||||||
|
return Err(match restore_with(control, &journal) {
|
||||||
|
Ok(()) => primary,
|
||||||
|
Err(rollback) => invalid(format!(
|
||||||
|
"{primary}; OpenWrt bridge rollback failed: {rollback}"
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(journal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_with(control: &mut impl Control, journal: &Journal) -> Result<()> {
|
||||||
|
let config = control.config()?;
|
||||||
|
let edits = restore_plan(&config, journal)?;
|
||||||
|
if !edits.is_empty() {
|
||||||
|
control.apply(&config, &edits)?;
|
||||||
|
}
|
||||||
|
control.reload()?;
|
||||||
|
control.wait(journal, false)?;
|
||||||
|
control.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct System;
|
||||||
|
impl Control for System {
|
||||||
|
fn config(&mut self) -> Result<Config> {
|
||||||
|
if Path::new("/var/run/uci/network").exists() {
|
||||||
|
return Err(invalid("A runtime network UCI override is active; OTG bridge configuration cannot safely replace it"));
|
||||||
|
}
|
||||||
|
let pending = run_command("uci", &["-q", "changes", "network"])?;
|
||||||
|
if !pending.stdout.is_empty() {
|
||||||
|
return Err(invalid(
|
||||||
|
"Save or discard pending network UCI changes before changing the OTG bridge",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let output = run_command("ubus", &["call", "uci", "get", r#"{"config":"network"}"#])?;
|
||||||
|
let value: Value = serde_json::from_slice(&output.stdout).map_err(io_error)?;
|
||||||
|
serde_json::from_value(value.get("values").cloned().ok_or_else(|| {
|
||||||
|
invalid("Invalid ubus UCI network response; rpcd UCI service is required")
|
||||||
|
})?)
|
||||||
|
.map_err(io_error)
|
||||||
|
}
|
||||||
|
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()> {
|
||||||
|
if &self.config()? != original {
|
||||||
|
return Err(invalid(
|
||||||
|
"Network configuration changed concurrently; retry the bridge operation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = Path::new("/etc/config/network");
|
||||||
|
if path.is_symlink() {
|
||||||
|
return Err(invalid("Symlinked network configuration is unsupported"));
|
||||||
|
}
|
||||||
|
let before = fs::read(path).map_err(io_error)?;
|
||||||
|
let directory = tempfile::tempdir().map_err(io_error)?;
|
||||||
|
let copy = directory.path().join("network");
|
||||||
|
fs::write(©, &before).map_err(io_error)?;
|
||||||
|
let delta = directory.path().join("delta");
|
||||||
|
fs::create_dir(&delta).map_err(io_error)?;
|
||||||
|
let confdir = directory
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| invalid("Invalid UCI temporary directory"))?;
|
||||||
|
let savedir = delta
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| invalid("Invalid UCI delta directory"))?;
|
||||||
|
for edit in edits {
|
||||||
|
let (command, argument) = match edit {
|
||||||
|
Edit::Set(key, value) => ("set", format!("network.{key}={value}")),
|
||||||
|
Edit::Delete(key) => ("delete", format!("network.{key}")),
|
||||||
|
Edit::AddList(key, value) => ("add_list", format!("network.{key}={value}")),
|
||||||
|
Edit::DelList(key, value) => ("del_list", format!("network.{key}={value}")),
|
||||||
|
};
|
||||||
|
run_command(
|
||||||
|
"uci",
|
||||||
|
&["-q", "-c", confdir, "-t", savedir, command, &argument],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
run_command(
|
||||||
|
"uci",
|
||||||
|
&["-q", "-c", confdir, "-t", savedir, "commit", "network"],
|
||||||
|
)?;
|
||||||
|
let mut replacement =
|
||||||
|
tempfile::NamedTempFile::new_in(path.parent().unwrap()).map_err(io_error)?;
|
||||||
|
replacement
|
||||||
|
.as_file()
|
||||||
|
.set_permissions(fs::metadata(path).map_err(io_error)?.permissions())
|
||||||
|
.map_err(io_error)?;
|
||||||
|
replacement
|
||||||
|
.write_all(&fs::read(©).map_err(io_error)?)
|
||||||
|
.map_err(io_error)?;
|
||||||
|
replacement.as_file().sync_all().map_err(io_error)?;
|
||||||
|
if fs::read(path).map_err(io_error)? != before || &self.config()? != original {
|
||||||
|
return Err(invalid(
|
||||||
|
"Network configuration changed concurrently; original configuration was preserved",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
replacement.persist(path).map_err(io_error)?;
|
||||||
|
fs::File::open(path.parent().unwrap())
|
||||||
|
.and_then(|f| f.sync_all())
|
||||||
|
.map_err(io_error)
|
||||||
|
}
|
||||||
|
fn reload(&mut self) -> Result<()> {
|
||||||
|
run_command("ubus", &["call", "network", "reload"]).map(|_| ())
|
||||||
|
}
|
||||||
|
fn wait(&mut self, journal: &Journal, active: bool) -> Result<()> {
|
||||||
|
let interface = if active {
|
||||||
|
&journal.bridge
|
||||||
|
} else {
|
||||||
|
&journal.uplink
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(35);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let members = !active
|
||||||
|
|| (Path::new("/sys/class/net")
|
||||||
|
.join(interface)
|
||||||
|
.join("brif")
|
||||||
|
.join(&journal.usb)
|
||||||
|
.exists()
|
||||||
|
&& (journal.created_section.is_none()
|
||||||
|
|| Path::new("/sys/class/net")
|
||||||
|
.join(interface)
|
||||||
|
.join("brif")
|
||||||
|
.join(&journal.uplink)
|
||||||
|
.exists()));
|
||||||
|
if members
|
||||||
|
&& first_ipv4_address(interface).is_some()
|
||||||
|
&& (!journal.had_default_route || default_route(interface).is_some())
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
Err(invalid(format!("OpenWrt interface {interface} did not restore its IPv4 address/default route or bridge ports in time")))
|
||||||
|
}
|
||||||
|
fn save(&mut self, journal: &Journal) -> Result<()> {
|
||||||
|
let path = Path::new(JOURNAL_PATH);
|
||||||
|
if path.exists() {
|
||||||
|
return Err(invalid("An OpenWrt bridge recovery journal already exists; recover it before activating another bridge"));
|
||||||
|
}
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).map_err(io_error)?;
|
||||||
|
let mut file = tempfile::NamedTempFile::new_in(path.parent().unwrap()).map_err(io_error)?;
|
||||||
|
file.as_file()
|
||||||
|
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||||
|
.map_err(io_error)?;
|
||||||
|
file.write_all(&serde_json::to_vec(journal).map_err(io_error)?)
|
||||||
|
.map_err(io_error)?;
|
||||||
|
file.as_file().sync_all().map_err(io_error)?;
|
||||||
|
file.persist_noclobber(path).map_err(io_error)?;
|
||||||
|
fs::File::open(path.parent().unwrap())
|
||||||
|
.and_then(|f| f.sync_all())
|
||||||
|
.map_err(io_error)
|
||||||
|
}
|
||||||
|
fn clear(&mut self) -> Result<()> {
|
||||||
|
match fs::remove_file(JOURNAL_PATH) {
|
||||||
|
Ok(()) => fs::File::open(Path::new(JOURNAL_PATH).parent().unwrap())
|
||||||
|
.and_then(|f| f.sync_all())
|
||||||
|
.map_err(io_error),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(io_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn activate(selected: &NetworkInterfaceInfo, usb: &str) -> Result<Journal> {
|
||||||
|
for name in ["uci", "ubus", "ip"] {
|
||||||
|
ensure_command(name)?;
|
||||||
|
}
|
||||||
|
if !bridgeable_device(&selected.name) {
|
||||||
|
return Err(invalid("OpenWrt bridge uplink must be Ethernet or an existing bridge; ordinary Wi-Fi clients cannot be bridged"));
|
||||||
|
}
|
||||||
|
let existing_bridge = Path::new("/sys/class/net")
|
||||||
|
.join(&selected.name)
|
||||||
|
.join("bridge")
|
||||||
|
.is_dir();
|
||||||
|
if existing_bridge
|
||||||
|
&& fs::read_to_string(
|
||||||
|
Path::new("/sys/class/net")
|
||||||
|
.join(&selected.name)
|
||||||
|
.join("bridge/vlan_filtering"),
|
||||||
|
)
|
||||||
|
.is_ok_and(|value| value.trim() == "1")
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"VLAN-filtered bridges require an explicit USB VLAN configuration and are unsupported",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !existing_bridge && Path::new("/sys/class/net").join(BRIDGE).exists() {
|
||||||
|
return Err(invalid(
|
||||||
|
"okvm-br0 already exists; refusing to replace another bridge",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let usb_path = Path::new("/sys/class/net").join(usb);
|
||||||
|
if !device_name(usb) || !usb_path.is_dir() || usb_path.join("master").exists() {
|
||||||
|
return Err(invalid(
|
||||||
|
"USB network interface is unavailable or already enslaved",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mac = fs::read_to_string(
|
||||||
|
Path::new("/sys/class/net")
|
||||||
|
.join(&selected.name)
|
||||||
|
.join("address"),
|
||||||
|
)
|
||||||
|
.map_err(io_error)?;
|
||||||
|
activate_with(&mut System, selected, usb, mac.trim(), existing_bridge)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deactivate(journal: &Journal) -> Result<()> {
|
||||||
|
restore_with(&mut System, journal)
|
||||||
|
}
|
||||||
|
pub(super) fn recover() -> Result<()> {
|
||||||
|
let contents = match fs::read(JOURNAL_PATH) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(error) => return Err(io_error(error)),
|
||||||
|
};
|
||||||
|
let journal: Journal = serde_json::from_slice(&contents).map_err(io_error)?;
|
||||||
|
restore_with(&mut System, &journal)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
struct Fake {
|
||||||
|
config: Config,
|
||||||
|
journal: Option<Journal>,
|
||||||
|
reload_failures: usize,
|
||||||
|
fail_active_wait: bool,
|
||||||
|
saved_before_apply: bool,
|
||||||
|
}
|
||||||
|
impl Control for Fake {
|
||||||
|
fn config(&mut self) -> Result<Config> {
|
||||||
|
Ok(self.config.clone())
|
||||||
|
}
|
||||||
|
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()> {
|
||||||
|
assert_eq!(original, &self.config);
|
||||||
|
assert!(self.journal.is_some());
|
||||||
|
self.saved_before_apply = true;
|
||||||
|
for edit in edits {
|
||||||
|
match edit {
|
||||||
|
Edit::Set(key, value) => match key.split_once('.') {
|
||||||
|
Some((name, key)) => {
|
||||||
|
self.config
|
||||||
|
.get_mut(name)
|
||||||
|
.unwrap()
|
||||||
|
.insert(key.to_owned(), json!(value));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.config.insert(
|
||||||
|
key.clone(),
|
||||||
|
json!({".type":value}).as_object().unwrap().clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Edit::Delete(key) => {
|
||||||
|
self.config.remove(key);
|
||||||
|
}
|
||||||
|
Edit::AddList(key, value) => {
|
||||||
|
let (name, key) = key.split_once('.').unwrap();
|
||||||
|
self.config
|
||||||
|
.get_mut(name)
|
||||||
|
.unwrap()
|
||||||
|
.entry(key.to_owned())
|
||||||
|
.or_insert(json!([]))
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(json!(value));
|
||||||
|
}
|
||||||
|
Edit::DelList(key, value) => {
|
||||||
|
let (name, key) = key.split_once('.').unwrap();
|
||||||
|
self.config
|
||||||
|
.get_mut(name)
|
||||||
|
.unwrap()
|
||||||
|
.get_mut(key)
|
||||||
|
.unwrap()
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|v| v != value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn reload(&mut self) -> Result<()> {
|
||||||
|
if self.reload_failures > 0 {
|
||||||
|
self.reload_failures -= 1;
|
||||||
|
return Err(invalid("simulated reload failure"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn wait(&mut self, _: &Journal, active: bool) -> Result<()> {
|
||||||
|
if active && self.fail_active_wait {
|
||||||
|
Err(invalid("simulated DHCP timeout"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn save(&mut self, journal: &Journal) -> Result<()> {
|
||||||
|
assert!(self.journal.is_none());
|
||||||
|
self.journal = Some(journal.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn clear(&mut self) -> Result<()> {
|
||||||
|
self.journal = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fixture(proto: &str) -> (Fake, NetworkInterfaceInfo) {
|
||||||
|
let config: Config = serde_json::from_value(json!({
|
||||||
|
"lan":{".type":"interface", "device":"eth0", "proto":proto, "ipaddr":"192.0.2.5", "dns":["192.0.2.1"], "gateway":"192.0.2.1"},
|
||||||
|
"lan6":{".type":"interface", "device":"eth0", "proto":"dhcpv6"},
|
||||||
|
"wan":{".type":"interface", "device":"eth1", "proto":"dhcp"}
|
||||||
|
})).unwrap();
|
||||||
|
let selected = NetworkInterfaceInfo {
|
||||||
|
name: "eth0".into(),
|
||||||
|
connection: "lan".into(),
|
||||||
|
interface_type: "netifd".into(),
|
||||||
|
state: "connected".into(),
|
||||||
|
addresses: vec!["192.0.2.5/24".into()],
|
||||||
|
has_default_route: true,
|
||||||
|
bridge_supported: true,
|
||||||
|
reason: None,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
Fake {
|
||||||
|
config,
|
||||||
|
journal: None,
|
||||||
|
reload_failures: 0,
|
||||||
|
fail_active_wait: false,
|
||||||
|
saved_before_apply: false,
|
||||||
|
},
|
||||||
|
selected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ethernet_transaction_keeps_static_ip_dns_routes_ipv6_and_other_networks() {
|
||||||
|
let (mut fake, selected) = fixture("static");
|
||||||
|
let original = fake.config.clone();
|
||||||
|
let journal =
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
|
||||||
|
assert!(fake.saved_before_apply);
|
||||||
|
for name in ["lan", "lan6"] {
|
||||||
|
let mut expected = original[name].clone();
|
||||||
|
expected.insert("device".into(), json!(BRIDGE));
|
||||||
|
assert_eq!(fake.config[name], expected);
|
||||||
|
}
|
||||||
|
assert_eq!(fake.config["wan"], original["wan"]);
|
||||||
|
assert_eq!(
|
||||||
|
fake.config[&journal.bridge_section]["macaddr"],
|
||||||
|
"02:00:00:00:00:01"
|
||||||
|
);
|
||||||
|
fake.config
|
||||||
|
.get_mut("wan")
|
||||||
|
.unwrap()
|
||||||
|
.insert("metric".into(), json!("50"));
|
||||||
|
restore_with(&mut fake, &journal).unwrap();
|
||||||
|
assert_eq!(fake.config["lan"], original["lan"]);
|
||||||
|
assert_eq!(fake.config["lan6"], original["lan6"]);
|
||||||
|
assert_eq!(fake.config["wan"]["metric"], "50");
|
||||||
|
assert!(fake.journal.is_none());
|
||||||
|
assert!(!fake.config.contains_key(&journal.bridge_section));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn existing_bridge_only_adds_and_removes_usb_preserving_new_ports() {
|
||||||
|
let (mut fake, mut selected) = fixture("dhcp");
|
||||||
|
selected.name = "br-lan".into();
|
||||||
|
fake.config
|
||||||
|
.get_mut("lan")
|
||||||
|
.unwrap()
|
||||||
|
.insert("device".into(), json!("br-lan"));
|
||||||
|
fake.config.insert("cfg123".into(), json!({".type":"device", "name":"br-lan", "type":"bridge", "ports":["eth0"], "stp":"1"}).as_object().unwrap().clone());
|
||||||
|
let journal =
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", true).unwrap();
|
||||||
|
assert!(journal.created_section.is_none());
|
||||||
|
assert_eq!(fake.config["cfg123"]["ports"], json!(["eth0", "usb0"]));
|
||||||
|
fake.config
|
||||||
|
.get_mut("cfg123")
|
||||||
|
.unwrap()
|
||||||
|
.get_mut("ports")
|
||||||
|
.unwrap()
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(json!("eth2"));
|
||||||
|
restore_with(&mut fake, &journal).unwrap();
|
||||||
|
assert_eq!(fake.config["cfg123"]["ports"], json!(["eth0", "eth2"]));
|
||||||
|
assert_eq!(fake.config["cfg123"]["stp"], "1");
|
||||||
|
assert_eq!(fake.config["lan"]["device"], "br-lan");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn reload_failure_and_dhcp_timeout_restore_original_before_returning_error() {
|
||||||
|
for reload_failure in [true, false] {
|
||||||
|
let (mut fake, selected) = fixture("dhcp");
|
||||||
|
let original = fake.config.clone();
|
||||||
|
fake.reload_failures = usize::from(reload_failure);
|
||||||
|
fake.fail_active_wait = !reload_failure;
|
||||||
|
assert!(
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(fake.config, original);
|
||||||
|
assert!(fake.journal.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn failed_rollback_keeps_journal_and_can_be_retried() {
|
||||||
|
let (mut fake, selected) = fixture("dhcp");
|
||||||
|
let original = fake.config.clone();
|
||||||
|
fake.reload_failures = 2;
|
||||||
|
let error =
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("rollback failed"));
|
||||||
|
let journal = fake.journal.clone().unwrap();
|
||||||
|
restore_with(&mut fake, &journal).unwrap();
|
||||||
|
assert_eq!(fake.config, original);
|
||||||
|
assert!(fake.journal.is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn reboot_recovery_accepts_serialized_journal_and_already_restored_config() {
|
||||||
|
let (mut fake, selected) = fixture("dhcp");
|
||||||
|
let original = fake.config.clone();
|
||||||
|
let journal =
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
|
||||||
|
let recovered: Journal =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&journal).unwrap()).unwrap();
|
||||||
|
restore_with(&mut fake, &recovered).unwrap();
|
||||||
|
fake.journal = Some(recovered.clone());
|
||||||
|
restore_with(&mut fake, &recovered).unwrap();
|
||||||
|
assert_eq!(fake.config, original);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn externally_changed_interface_or_owned_bridge_is_never_overwritten() {
|
||||||
|
for change_interface in [true, false] {
|
||||||
|
let (mut fake, selected) = fixture("dhcp");
|
||||||
|
let journal =
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
|
||||||
|
let (name, key, value) = if change_interface {
|
||||||
|
("lan", "device", "eth2")
|
||||||
|
} else {
|
||||||
|
(journal.bridge_section.as_str(), "stp", "1")
|
||||||
|
};
|
||||||
|
fake.config
|
||||||
|
.get_mut(name)
|
||||||
|
.unwrap()
|
||||||
|
.insert(key.into(), json!(value));
|
||||||
|
let before = fake.config.clone();
|
||||||
|
assert!(restore_with(&mut fake, &journal).is_err());
|
||||||
|
assert_eq!(fake.config, before);
|
||||||
|
assert!(fake.journal.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_ppp_legacy_and_busy_usb_before_writing_journal() {
|
||||||
|
for kind in 0..3 {
|
||||||
|
let (mut fake, selected) = fixture(if kind == 0 { "pppoe" } else { "dhcp" });
|
||||||
|
if kind == 1 {
|
||||||
|
fake.config
|
||||||
|
.get_mut("lan")
|
||||||
|
.unwrap()
|
||||||
|
.insert("ifname".into(), json!("eth0"));
|
||||||
|
}
|
||||||
|
if kind == 2 {
|
||||||
|
fake.config
|
||||||
|
.get_mut("wan")
|
||||||
|
.unwrap()
|
||||||
|
.insert("device".into(), json!("usb0"));
|
||||||
|
}
|
||||||
|
let before = fake.config.clone();
|
||||||
|
assert!(
|
||||||
|
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(fake.config, before);
|
||||||
|
assert!(fake.journal.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_vlan_filtered_bridge_without_mutations() {
|
||||||
|
let (mut fake, mut selected) = fixture("dhcp");
|
||||||
|
selected.name = "br-lan".into();
|
||||||
|
fake.config
|
||||||
|
.get_mut("lan")
|
||||||
|
.unwrap()
|
||||||
|
.insert("device".into(), json!("br-lan"));
|
||||||
|
fake.config.insert(
|
||||||
|
"bridge".into(),
|
||||||
|
json!({".type":"device", "name":"br-lan", "type":"bridge", "ports":["eth0"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
fake.config.insert(
|
||||||
|
"vlan".into(),
|
||||||
|
json!({".type":"bridge-vlan", "device":"br-lan", "vlan":"1"})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
assert!(activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", true).is_err());
|
||||||
|
assert!(fake.journal.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/otg/network_backend.rs
Normal file
74
src/otg/network_backend.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//! Select OpenWrt netifd or a running NetworkManager, with an explicit override.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum NetworkBackend {
|
||||||
|
NetworkManager,
|
||||||
|
Netifd,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_backend(
|
||||||
|
requested: Option<&str>,
|
||||||
|
openwrt_release_present: bool,
|
||||||
|
) -> Result<NetworkBackend, String> {
|
||||||
|
match requested.unwrap_or("auto") {
|
||||||
|
"auto" if openwrt_release_present => Ok(NetworkBackend::Netifd),
|
||||||
|
"auto" | "networkmanager" => Ok(NetworkBackend::NetworkManager),
|
||||||
|
"netifd" => Ok(NetworkBackend::Netifd),
|
||||||
|
value => Err(format!(
|
||||||
|
"Unknown ONE_KVM_NETWORK_BACKEND={value}; expected auto, networkmanager or netifd"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_backend() -> Result<NetworkBackend, String> {
|
||||||
|
let requested = std::env::var("ONE_KVM_NETWORK_BACKEND").ok();
|
||||||
|
let backend = select_backend(
|
||||||
|
requested.as_deref(),
|
||||||
|
std::path::Path::new("/etc/openwrt_release").is_file(),
|
||||||
|
)?;
|
||||||
|
if backend == NetworkBackend::NetworkManager {
|
||||||
|
let status = std::process::Command::new("nmcli")
|
||||||
|
.env("LC_ALL", "C")
|
||||||
|
.args(["-t", "-f", "RUNNING", "general"])
|
||||||
|
.output()
|
||||||
|
.map_err(|_| {
|
||||||
|
"OTG bridging requires NetworkManager and nmcli on this system".to_string()
|
||||||
|
})?;
|
||||||
|
if !status.status.success() || String::from_utf8_lossy(&status.stdout).trim() != "running" {
|
||||||
|
return Err("NetworkManager is not running; OTG bridging cannot manage this system's interfaces".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_selection_respects_openwrt_marker() {
|
||||||
|
assert_eq!(select_backend(None, true), Ok(NetworkBackend::Netifd));
|
||||||
|
assert_eq!(
|
||||||
|
select_backend(Some("auto"), false),
|
||||||
|
Ok(NetworkBackend::NetworkManager)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_selection_is_respected() {
|
||||||
|
assert_eq!(
|
||||||
|
select_backend(Some("networkmanager"), true),
|
||||||
|
Ok(NetworkBackend::NetworkManager)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
select_backend(Some("netifd"), false),
|
||||||
|
Ok(NetworkBackend::Netifd)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_selection_is_not_silently_ignored() {
|
||||||
|
assert!(select_backend(Some("typo"), true).is_err());
|
||||||
|
assert!(select_backend(Some(""), false).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -570,6 +570,8 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
|||||||
})
|
})
|
||||||
.is_some_and(|name| {
|
.is_some_and(|name| {
|
||||||
name == "okvm-br0" || name == config.otg_network.bridge_interface
|
name == "okvm-br0" || name == config.otg_network.bridge_interface
|
||||||
|
|| (config.otg_network.bridge_interface.is_empty()
|
||||||
|
&& std::path::Path::new("/sys/class/net").join(name).join("bridge").is_dir())
|
||||||
});
|
});
|
||||||
push_otg_check(
|
push_otg_check(
|
||||||
&mut checks,
|
&mut checks,
|
||||||
|
|||||||
Reference in New Issue
Block a user