This commit is contained in:
rchac committed 2026-05-06 22:12:01 +00:00
1 parent 0523c2fc20
commit 6eac4c10d4
15 files changed
+697 -50

No files matched your search

+2
View File
@@ -25,12 +25,14 @@ disable_offload = [ "gso", "tso", "lro", "sg", "gro" ]
use_xdp_bridge = false
to_internet = "eth0"
to_network = "eth1"
# mtu = 1500
# OR:
#[single_interface]
#interface = "eth0"
#internet_vlan = 2
#network_vlan = 3
# mtu = 1500
[queues]
# Global default SQM is a single token list applied to both directions.
+2
View File
@@ -11294,6 +11294,7 @@ mod tests {
use_xdp_bridge: false,
to_internet: "lo".to_string(),
to_network: "lo".to_string(),
mtu: None,
}),
..lqos_config::Config::default()
};
@@ -13142,6 +13143,7 @@ mod tests {
use_xdp_bridge: false,
to_internet: "__bakery-missing-wan__".to_string(),
to_network: "__bakery-missing-lan__".to_string(),
mtu: None,
}),
..Config::default()
};
@@ -182,6 +182,7 @@ fn migrate_bridge(
interface: python_config.interface_a.clone(),
internet_vlan: python_config.stick_vlan_a as u32,
network_vlan: python_config.stick_vlan_b as u32,
mtu: None,
});
} else {
new_config.single_interface = None;
@@ -193,6 +194,7 @@ fn migrate_bridge(
.use_xdp_bridge,
to_internet: python_config.interface_b.clone(),
to_network: python_config.interface_a.clone(),
mtu: None,
});
}
Ok(())
@@ -16,6 +16,10 @@ pub struct BridgeConfig {
/// The name of the second interface, facing the LAN
pub to_network: String,
/// Optional MTU for LibreQoS-managed Linux bridge interfaces.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u32>,
}
impl Default for BridgeConfig {
@@ -24,6 +28,7 @@ impl Default for BridgeConfig {
use_xdp_bridge: true,
to_internet: "eth0".to_string(),
to_network: "eth1".to_string(),
mtu: None,
}
}
}
@@ -39,6 +44,10 @@ pub struct SingleInterfaceConfig {
/// The VLAN ID facing the LAN
pub network_vlan: u32,
/// Optional MTU for the LibreQoS-managed trunk interface.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u32>,
}
impl Default for SingleInterfaceConfig {
@@ -47,6 +56,7 @@ impl Default for SingleInterfaceConfig {
interface: "eth0".to_string(),
internet_vlan: 2,
network_vlan: 3,
mtu: None,
}
}
}
@@ -26,12 +26,14 @@ disable_offload = [ "gso", "tso", "lro", "sg", "gro" ]
use_xdp_bridge = true
to_internet = "eth0"
to_network = "eth1"
# mtu = 1500
# OR:
#[single_interface]
#interface = "eth0"
#internet_vlan = 2
#network_vlan = 3
# mtu = 1500
[queues]
default_sqm = "cake diffserv4"
@@ -15,6 +15,9 @@ fn default_true() -> bool {
true
}
const MIN_INTERFACE_MTU: u32 = 576;
const MAX_INTERFACE_MTU: u32 = 9216;
fn default_rtt_green_ms() -> u32 {
0
}
@@ -27,6 +30,17 @@ fn default_rtt_red_ms() -> u32 {
200
}
fn validate_interface_mtu(field_name: &str, mtu: Option<u32>) -> Result<(), String> {
if let Some(mtu) = mtu
&& !(MIN_INTERFACE_MTU..=MAX_INTERFACE_MTU).contains(&mtu)
{
return Err(format!(
"{field_name} must be between {MIN_INTERFACE_MTU} and {MAX_INTERFACE_MTU}"
));
}
Ok(())
}
/// RTT color scale thresholds (milliseconds) used by the web UI.
///
/// These represent the color "anchor points" for RTT:
@@ -225,6 +239,12 @@ impl Config {
.to_string(),
);
}
if let Some(bridge) = &self.bridge {
validate_interface_mtu("bridge.mtu", bridge.mtu)?;
}
if let Some(single_interface) = &self.single_interface {
validate_interface_mtu("single_interface.mtu", single_interface.mtu)?;
}
if self.version.trim() != "1.5" {
return Err(format!(
"Configuration file is at version [{}], but this version of lqos only supports version 1.5.0",
@@ -569,6 +589,7 @@ impl Config {
#[cfg(test)]
mod test {
use super::super::bridge::{BridgeConfig, SingleInterfaceConfig};
use super::{Config, RttThresholds};
fn remove_sections(raw: &str, sections: &[&str]) -> String {
@@ -772,6 +793,86 @@ mod test {
assert!(cfg.validate().is_err());
}
#[test]
fn bridge_mtu_defaults_to_none_when_omitted() {
let config =
Config::load_from_string(include_str!("example.toml")).expect("example should load");
assert_eq!(config.bridge.as_ref().and_then(|bridge| bridge.mtu), None);
}
#[test]
fn interface_mtu_values_deserialize() {
let bridge_raw = include_str!("example.toml")
.replace("to_network = \"eth1\"", "to_network = \"eth1\"\nmtu = 9000");
let bridge_config = Config::load_from_string(&bridge_raw).expect("bridge MTU should load");
assert_eq!(
bridge_config.bridge.as_ref().and_then(|bridge| bridge.mtu),
Some(9000)
);
let single_raw = remove_sections(include_str!("example.toml"), &["bridge"])
+ r#"
[single_interface]
interface = "eth0"
internet_vlan = 2
network_vlan = 3
mtu = 1500
"#;
let single_config =
Config::load_from_string(&single_raw).expect("single-interface MTU should load");
assert_eq!(
single_config
.single_interface
.as_ref()
.and_then(|single| single.mtu),
Some(1500)
);
}
#[test]
fn interface_mtu_validation_rejects_out_of_range_values() {
let mut bridge_config = Config {
bridge: Some(BridgeConfig {
mtu: Some(575),
..BridgeConfig::default()
}),
..Config::default()
};
let bridge_error = bridge_config
.validate()
.expect_err("bridge.mtu below range should fail");
assert!(bridge_error.contains("bridge.mtu"));
bridge_config.bridge.as_mut().expect("bridge").mtu = Some(9217);
let bridge_error = bridge_config
.validate()
.expect_err("bridge.mtu above range should fail");
assert!(bridge_error.contains("bridge.mtu"));
let mut single_config = Config {
bridge: None,
single_interface: Some(SingleInterfaceConfig {
mtu: Some(575),
..SingleInterfaceConfig::default()
}),
..Config::default()
};
let single_error = single_config
.validate()
.expect_err("single_interface.mtu below range should fail");
assert!(single_error.contains("single_interface.mtu"));
single_config
.single_interface
.as_mut()
.expect("single interface")
.mtu = Some(9217);
let single_error = single_config
.validate()
.expect_err("single_interface.mtu above range should fail");
assert!(single_error.contains("single_interface.mtu"));
}
#[test]
fn treeguard_defaults_match_default_on_rollout() {
let cfg = Config::default();
+132 -10
View File
@@ -68,6 +68,7 @@ enum RequestedMode {
LinuxBridge {
to_internet: String,
to_network: String,
mtu: Option<u32>,
},
XdpBridge {
to_internet: String,
@@ -75,6 +76,7 @@ enum RequestedMode {
},
SingleInterface {
interface: String,
mtu: Option<u32>,
},
Unknown,
}
@@ -307,11 +309,13 @@ fn requested_mode(config: &Config) -> RequestedMode {
RequestedMode::LinuxBridge {
to_internet: bridge.to_internet.trim().to_string(),
to_network: bridge.to_network.trim().to_string(),
mtu: bridge.mtu,
}
}
} else if let Some(single) = &config.single_interface {
RequestedMode::SingleInterface {
interface: single.interface.trim().to_string(),
mtu: single.mtu,
}
} else {
RequestedMode::Unknown
@@ -332,6 +336,7 @@ fn selected_interfaces(mode: &RequestedMode) -> Vec<String> {
RequestedMode::LinuxBridge {
to_internet,
to_network,
..
}
| RequestedMode::XdpBridge {
to_internet,
@@ -340,7 +345,7 @@ fn selected_interfaces(mode: &RequestedMode) -> Vec<String> {
.into_iter()
.filter(|iface| !iface.is_empty())
.collect(),
RequestedMode::SingleInterface { interface } => {
RequestedMode::SingleInterface { interface, .. } => {
if interface.is_empty() {
Vec::new()
} else {
@@ -351,21 +356,38 @@ fn selected_interfaces(mode: &RequestedMode) -> Vec<String> {
}
}
fn mtu_yaml_line(mtu: Option<u32>, indent: &str) -> String {
mtu.map(|value| format!("{indent}mtu: {value}\n"))
.unwrap_or_default()
}
fn managed_linux_bridge_yaml(to_internet: &str, to_network: &str, mtu: Option<u32>) -> String {
let ethernet_mtu = mtu_yaml_line(mtu, " ");
let bridge_mtu = mtu_yaml_line(mtu, " ");
format!(
"network:\n version: 2\n ethernets:\n {to_internet}:\n dhcp4: false\n dhcp6: false\n{ethernet_mtu} {to_network}:\n dhcp4: false\n dhcp6: false\n{ethernet_mtu} bridges:\n br0:\n{bridge_mtu} interfaces:\n - {to_internet}\n - {to_network}\n"
)
}
fn managed_single_interface_yaml(interface: &str, mtu: Option<u32>) -> String {
let mtu_line = mtu_yaml_line(mtu, " ");
format!(
"network:\n version: 2\n ethernets:\n {interface}:\n dhcp4: false\n dhcp6: false\n{mtu_line}"
)
}
fn managed_preview_yaml(mode: &RequestedMode) -> (Option<String>, Option<String>) {
match mode {
RequestedMode::LinuxBridge {
to_internet,
to_network,
mtu,
} if !to_internet.is_empty() && !to_network.is_empty() => (
Some(format!(
"network:\n version: 2\n ethernets:\n {to_internet}:\n dhcp4: false\n dhcp6: false\n {to_network}:\n dhcp4: false\n dhcp6: false\n bridges:\n br0:\n interfaces:\n - {to_internet}\n - {to_network}\n"
)),
Some(managed_linux_bridge_yaml(to_internet, to_network, *mtu)),
None,
),
RequestedMode::SingleInterface { interface } if !interface.is_empty() => (
Some(format!(
"network:\n version: 2\n ethernets:\n {interface}:\n dhcp4: false\n dhcp6: false\n"
)),
RequestedMode::SingleInterface { interface, mtu } if !interface.is_empty() => (
Some(managed_single_interface_yaml(interface, *mtu)),
None,
),
RequestedMode::XdpBridge { .. } => (
@@ -598,6 +620,7 @@ pub(crate) fn adoption_rewrite_for_path(path: &Path, config: &Config) -> Result<
RequestedMode::LinuxBridge {
to_internet,
to_network,
..
} => {
let mut removed_bridge = false;
doc.network.ethernets.remove(&to_internet);
@@ -618,7 +641,7 @@ pub(crate) fn adoption_rewrite_for_path(path: &Path, config: &Config) -> Result<
));
}
}
RequestedMode::SingleInterface { interface } => {
RequestedMode::SingleInterface { interface, .. } => {
if doc.network.ethernets.remove(&interface).is_none() {
return Err(format!(
"Unable to find interface {interface} in {} for adoption.",
@@ -658,6 +681,7 @@ fn assess_file(path: &Path, doc: &NetplanDocument, mode: &RequestedMode) -> File
RequestedMode::LinuxBridge {
to_internet,
to_network,
..
} => {
for iface in [to_internet, to_network] {
if let Some(cfg) = doc.network.ethernets.get(iface) {
@@ -724,7 +748,7 @@ fn assess_file(path: &Path, doc: &NetplanDocument, mode: &RequestedMode) -> File
}
}
}
RequestedMode::SingleInterface { interface } => {
RequestedMode::SingleInterface { interface, .. } => {
if let Some(cfg) = doc.network.ethernets.get(interface) {
relevant.insert(interface.clone());
if cfg.dhcp_disabled() && !cfg.has_l3_config() {
@@ -1113,6 +1137,7 @@ mod tests {
use_xdp_bridge: false,
to_internet: "ens19".to_string(),
to_network: "ens20".to_string(),
mtu: None,
}),
single_interface: None,
..lqos_config::Config::default()
@@ -1126,6 +1151,7 @@ mod tests {
interface: "ens19".to_string(),
internet_vlan: 2,
network_vlan: 3,
mtu: None,
}),
..lqos_config::Config::default()
}
@@ -1144,6 +1170,102 @@ mod tests {
BTreeMap::from([("ens19".to_string(), true), ("ens20".to_string(), true)])
}
#[test]
fn managed_linux_bridge_preview_includes_mtu_on_members_and_bridge() {
let mut config = linux_bridge_config();
config.bridge.as_mut().expect("bridge").mtu = Some(9000);
let root = test_env("bridge-mtu-preview");
let inspection = inspect_network_mode_with_paths(
&config,
&root.join("netplan"),
&root.join("pending"),
&BTreeSet::from(["ens19".to_string(), "ens20".to_string()]),
&queue_caps(),
);
let preview = inspection
.managed_preview_yaml
.expect("managed preview should exist");
assert_eq!(
preview,
"network:\n version: 2\n ethernets:\n ens19:\n dhcp4: false\n dhcp6: false\n mtu: 9000\n ens20:\n dhcp4: false\n dhcp6: false\n mtu: 9000\n bridges:\n br0:\n mtu: 9000\n interfaces:\n - ens19\n - ens20\n"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn managed_single_interface_preview_includes_mtu_on_trunk() {
let mut config = single_interface_config();
config
.single_interface
.as_mut()
.expect("single interface")
.mtu = Some(1500);
let root = test_env("single-mtu-preview");
let inspection = inspect_network_mode_with_paths(
&config,
&root.join("netplan"),
&root.join("pending"),
&BTreeSet::from(["ens19".to_string()]),
&BTreeMap::from([("ens19".to_string(), true)]),
);
let preview = inspection
.managed_preview_yaml
.expect("managed preview should exist");
assert_eq!(
preview,
"network:\n version: 2\n ethernets:\n ens19:\n dhcp4: false\n dhcp6: false\n mtu: 1500\n"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn managed_previews_omit_mtu_when_unset() {
let root = test_env("unset-mtu-preview");
let inspection = inspect_network_mode_with_paths(
&linux_bridge_config(),
&root.join("netplan"),
&root.join("pending"),
&BTreeSet::from(["ens19".to_string(), "ens20".to_string()]),
&queue_caps(),
);
let preview = inspection
.managed_preview_yaml
.expect("managed preview should exist");
assert!(!preview.contains("mtu:"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn xdp_bridge_mode_keeps_managed_preview_disabled() {
let mut config = linux_bridge_config();
let bridge = config.bridge.as_mut().expect("bridge");
bridge.use_xdp_bridge = true;
bridge.mtu = Some(9000);
let root = test_env("xdp-mtu-preview");
let inspection = inspect_network_mode_with_paths(
&config,
&root.join("netplan"),
&root.join("pending"),
&BTreeSet::from(["ens19".to_string(), "ens20".to_string()]),
&queue_caps(),
);
assert!(inspection.managed_preview_yaml.is_none());
assert!(
inspection
.preview_note
.as_deref()
.is_some_and(|note| note.contains("manual workflow"))
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn detects_external_compatible_linux_bridge() {
let root = test_env("external-compatible");
+56 -11
View File
@@ -170,7 +170,7 @@ fn system_interfaces() -> BTreeSet<String> {
default_net::get_interfaces()
.into_iter()
.map(|iface| iface.name)
.collect()
.collect::<BTreeSet<_>>()
}
fn supports_multi_queue(interface: &str) -> bool {
@@ -195,7 +195,14 @@ fn supports_multi_queue(interface: &str) -> bool {
/// Inspect the requested network mode using the helper's configured paths.
pub fn inspect_with_paths(paths: &HelperPaths, config: &Config) -> NetworkModeInspection {
let system_ifaces = system_interfaces();
inspect_with_paths_and_interfaces(paths, config, system_interfaces())
}
fn inspect_with_paths_and_interfaces(
paths: &HelperPaths,
config: &Config,
system_ifaces: BTreeSet<String>,
) -> NetworkModeInspection {
let queue_caps = system_ifaces
.iter()
.map(|iface| (iface.clone(), supports_multi_queue(iface)))
@@ -750,6 +757,15 @@ pub fn apply_transaction(
paths: &HelperPaths,
pending_children: &mut PendingChildren,
request: ApplyRequest,
) -> Result<ApplyResponse> {
apply_transaction_with_interfaces(paths, pending_children, request, system_interfaces())
}
fn apply_transaction_with_interfaces(
paths: &HelperPaths,
pending_children: &mut PendingChildren,
request: ApplyRequest,
system_ifaces: BTreeSet<String>,
) -> Result<ApplyResponse> {
normalize_pending_children(paths, pending_children)?;
if !list_pending_records(paths)?.is_empty() {
@@ -761,7 +777,7 @@ pub fn apply_transaction(
} else {
Config::default()
};
let inspection = inspect_with_paths(paths, &request.config);
let inspection = inspect_with_paths_and_interfaces(paths, &request.config, system_ifaces);
validate_apply_request(&request, &inspection, &previous_config)?;
let preview_yaml = inspection.managed_preview_yaml.as_ref().ok_or_else(|| {
@@ -1013,7 +1029,7 @@ esac
.find(|iface| iface.as_str() != "lo")
.cloned()
.or_else(|| interfaces.iter().next().cloned())
.expect("test host should expose at least one interface");
.unwrap_or_else(|| "ens19".to_string());
let secondary = interfaces
.iter()
.find(|iface| iface.as_str() != primary && iface.as_str() != "lo")
@@ -1024,10 +1040,19 @@ esac
.find(|iface| iface.as_str() != primary)
.cloned()
})
.expect("test host should expose a second distinct interface");
.unwrap_or_else(|| "ens20".to_string());
(primary, secondary)
}
fn test_system_interfaces() -> BTreeSet<String> {
let interfaces = system_interfaces();
if interfaces.is_empty() {
BTreeSet::from(["ens19".to_string(), "ens20".to_string()])
} else {
interfaces
}
}
fn linux_bridge_config() -> Config {
let (to_internet, to_network) = test_interfaces();
Config {
@@ -1035,6 +1060,7 @@ esac
use_xdp_bridge: false,
to_internet,
to_network,
mtu: None,
}),
single_interface: None,
..Config::default()
@@ -1058,7 +1084,13 @@ esac
confirm_dangerous_changes: true,
};
let apply = apply_transaction(&paths, &mut pending, request).expect("apply should succeed");
let apply = apply_transaction_with_interfaces(
&paths,
&mut pending,
request,
test_system_interfaces(),
)
.expect("apply should succeed");
assert!(apply.ok);
assert!(apply.operation.is_some());
@@ -1082,7 +1114,13 @@ esac
mode: ApplyMode::Apply,
confirm_dangerous_changes: true,
};
let apply = apply_transaction(&paths, &mut pending, request).expect("second apply");
let apply = apply_transaction_with_interfaces(
&paths,
&mut pending,
request,
test_system_interfaces(),
)
.expect("second apply");
let operation_id = apply
.operation
.as_ref()
@@ -1112,10 +1150,11 @@ esac
interface: test_interfaces().0,
internet_vlan: 2,
network_vlan: 3,
mtu: None,
});
next.bridge = None;
let apply = apply_transaction(
let apply = apply_transaction_with_interfaces(
&paths,
&mut pending,
ApplyRequest {
@@ -1125,6 +1164,7 @@ esac
mode: ApplyMode::Apply,
confirm_dangerous_changes: true,
},
test_system_interfaces(),
)
.expect("apply should succeed");
let backup_id = apply
@@ -1147,6 +1187,7 @@ esac
interface: test_interfaces().1,
internet_vlan: 10,
network_vlan: 11,
mtu: None,
}),
bridge: None,
..Config::default()
@@ -1181,7 +1222,7 @@ esac
let existing = linux_bridge_config();
write_config(&paths.config_path, &existing);
let apply = apply_transaction(
let apply = apply_transaction_with_interfaces(
&paths,
&mut pending,
ApplyRequest {
@@ -1191,6 +1232,7 @@ esac
mode: ApplyMode::Apply,
confirm_dangerous_changes: true,
},
test_system_interfaces(),
)
.expect("apply should succeed");
let operation_id = apply
@@ -1228,10 +1270,11 @@ esac
interface: test_interfaces().0,
internet_vlan: 2,
network_vlan: 3,
mtu: None,
});
next.bridge = None;
let apply = apply_transaction(
let apply = apply_transaction_with_interfaces(
&paths,
&mut pending,
ApplyRequest {
@@ -1241,6 +1284,7 @@ esac
mode: ApplyMode::Apply,
confirm_dangerous_changes: true,
},
test_system_interfaces(),
)
.expect("apply should succeed");
let operation_id = apply
@@ -1295,7 +1339,7 @@ esac
let existing = linux_bridge_config();
write_config(&paths.config_path, &existing);
let err = apply_transaction(
let err = apply_transaction_with_interfaces(
&paths,
&mut pending,
ApplyRequest {
@@ -1305,6 +1349,7 @@ esac
mode: ApplyMode::Apply,
confirm_dangerous_changes: true,
},
test_system_interfaces(),
)
.expect_err("apply should fail");
+38 -1
View File
@@ -42,6 +42,11 @@ pub(crate) enum CommitOutcome {
pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> lqos_config::Config {
let mut config = existing.unwrap_or_default();
let existing_bridge_mtu = config.bridge.as_ref().and_then(|bridge| bridge.mtu);
let existing_single_interface_mtu = config
.single_interface
.as_ref()
.and_then(|single| single.mtu);
let new_config = CURRENT_CONFIG.lock();
config.node_name = new_config.node_name.clone();
config.queues.downlink_bandwidth_mbps = new_config.mbps_to_internet;
@@ -55,6 +60,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
use_xdp_bridge: false,
to_internet: new_config.to_internet.clone(),
to_network: new_config.to_network.clone(),
mtu: existing_bridge_mtu,
});
}
BridgeMode::XDP => {
@@ -63,6 +69,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
use_xdp_bridge: true,
to_internet: new_config.to_internet.clone(),
to_network: new_config.to_network.clone(),
mtu: existing_bridge_mtu,
});
}
BridgeMode::Single => {
@@ -70,6 +77,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
interface: new_config.to_internet.clone(),
internet_vlan: new_config.internet_vlan,
network_vlan: new_config.network_vlan,
mtu: existing_single_interface_mtu,
});
config.bridge = None;
}
@@ -245,7 +253,8 @@ fn load_existing_or_default(event_log: &mut Vec<String>) -> Result<lqos_config::
#[cfg(test)]
mod tests {
use super::load_existing_or_default;
use super::{build_candidate_config, load_existing_or_default};
use crate::config_builder::{BridgeMode, CURRENT_CONFIG};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::fs;
@@ -282,4 +291,32 @@ mod tests {
lqos_config::clear_cached_config();
fs::remove_file(path).expect("remove temp config");
}
#[test]
fn build_candidate_config_preserves_existing_mtu_for_same_mode() {
let _guard = CONFIG_ENV_LOCK.lock();
let previous_builder = {
let mut builder = CURRENT_CONFIG.lock();
let previous = builder.clone();
builder.bridge_mode = BridgeMode::Linux;
builder.to_internet = "wan0".to_string();
builder.to_network = "lan0".to_string();
previous
};
let existing = lqos_config::Config {
bridge: Some(lqos_config::BridgeConfig {
mtu: Some(9000),
..lqos_config::BridgeConfig::default()
}),
..lqos_config::Config::default()
};
let candidate = build_candidate_config(Some(existing));
assert_eq!(
candidate.bridge.as_ref().and_then(|bridge| bridge.mtu),
Some(9000)
);
*CURRENT_CONFIG.lock() = previous_builder;
}
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import {hydrateDraftMtu, parseOptionalMtu} from "./network_mode_mtu.mjs";
test("accepts blank and valid MTU values", () => {
assert.deepEqual(parseOptionalMtu(""), { ok: true, value: null, error: "" });
assert.deepEqual(parseOptionalMtu(" "), { ok: true, value: null, error: "" });
assert.deepEqual(parseOptionalMtu("1500"), { ok: true, value: 1500, error: "" });
assert.deepEqual(parseOptionalMtu("9000"), { ok: true, value: 9000, error: "" });
});
test("rejects invalid MTU values", () => {
assert.equal(parseOptionalMtu("abc").ok, false);
assert.equal(parseOptionalMtu("1500.5").ok, false);
assert.equal(parseOptionalMtu("575").ok, false);
assert.equal(parseOptionalMtu("9217").ok, false);
});
test("returns normalized candidate values", () => {
assert.equal(parseOptionalMtu("9000").value, 9000);
assert.equal(parseOptionalMtu("").value, null);
});
test("hydrates pre-MTU drafts from live config", () => {
const liveConfig = {
bridge: { mtu: 9000 },
single_interface: { mtu: 1500 },
};
assert.equal(hydrateDraftMtu({ bridge: { to_internet: "eth0" } }, liveConfig).bridge.mtu, 9000);
assert.equal(
hydrateDraftMtu({ single_interface: { interface: "eth0" } }, liveConfig).single_interface.mtu,
1500
);
});
test("keeps explicit draft MTU values", () => {
const liveConfig = {
bridge: { mtu: 9000 },
single_interface: { mtu: 1500 },
};
assert.equal(hydrateDraftMtu({ bridge: { mtu: null } }, liveConfig).bridge.mtu, null);
assert.equal(hydrateDraftMtu({ single_interface: { mtu: 1400 } }, liveConfig).single_interface.mtu, 1400);
});
@@ -0,0 +1,29 @@
export const MTU_MIN = 576;
export const MTU_MAX = 9216;
export function parseOptionalMtu(rawValue) {
const trimmed = String(rawValue ?? "").trim();
if (!trimmed) {
return { ok: true, value: null, error: "" };
}
if (!/^\d+$/.test(trimmed)) {
return { ok: false, value: null, error: `MTU must be a whole number from ${MTU_MIN} through ${MTU_MAX}.` };
}
const value = Number.parseInt(trimmed, 10);
if (value < MTU_MIN || value > MTU_MAX) {
return { ok: false, value: null, error: `MTU must be from ${MTU_MIN} through ${MTU_MAX}.` };
}
return { ok: true, value, error: "" };
}
export function hydrateDraftMtu(draft, liveConfig) {
if (!draft) return null;
const hydrated = JSON.parse(JSON.stringify(draft));
if (hydrated.bridge && hydrated.bridge.mtu === undefined) {
hydrated.bridge.mtu = liveConfig?.bridge?.mtu ?? null;
}
if (hydrated.single_interface && hydrated.single_interface.mtu === undefined) {
hydrated.single_interface.mtu = liveConfig?.single_interface?.mtu ?? null;
}
return hydrated;
}
@@ -1,9 +1,14 @@
import {loadConfig, renderConfigMenu} from "./config/config_helper";
import {hydrateDraftMtu, MTU_MAX, MTU_MIN, parseOptionalMtu} from "./config/network_mode_mtu.mjs";
const DRAFT_KEY = "lqos-network-mode-draft";
const PENDING_OPERATION_KEY = "lqos-network-mode-pending";
const NETPLAN_TRY_TIMEOUT_MS = 30_000;
const RECONNECT_POLL_INTERVAL_MS = 2_000;
const BRIDGE_MTU_HELP = "Applies to the bridge members and br0.";
const SINGLE_INTERFACE_MTU_HELP = "Applies to the trunk interface.";
const MTU_LOCKED_HELP = "MTU cannot be changed while a network-mode operation is pending.";
const XDP_MTU_HELP = "XDP mode keeps this value for later, but LibreQoS will not apply it.";
let currentHelperStatus = null;
let currentInspection = null;
let currentPendingOperation = null;
@@ -71,7 +76,94 @@ function postJson(url, body) {
});
}
function setMtuFieldError(inputId, errorId, message) {
const input = document.getElementById(inputId);
const error = document.getElementById(errorId);
if (!input || !error) return;
input.setAttribute("aria-invalid", "true");
input.classList.add("is-invalid");
error.textContent = message;
}
function clearMtuFieldError(inputId, errorId) {
const input = document.getElementById(inputId);
const error = document.getElementById(errorId);
if (!input || !error) return;
input.removeAttribute("aria-invalid");
input.classList.remove("is-invalid");
error.textContent = "";
}
function clearMtuErrors() {
clearMtuFieldError("bridgeMtu", "bridgeMtuError");
clearMtuFieldError("singleInterfaceMtu", "singleInterfaceMtuError");
}
function applyMtuBounds() {
["bridgeMtu", "singleInterfaceMtu"].forEach((id) => {
const input = document.getElementById(id);
if (!input) return;
input.min = String(MTU_MIN);
input.max = String(MTU_MAX);
});
}
function validateMtuField(inputId, errorId, parser) {
const input = document.getElementById(inputId);
if (!input) return true;
const parsed = parser(input.value);
if (parsed.ok) {
clearMtuFieldError(inputId, errorId);
return true;
}
setMtuFieldError(inputId, errorId, parsed.error);
input.focus();
return false;
}
function currentBridgeMtuValue() {
const parsed = parseOptionalMtu(document.getElementById("bridgeMtu").value);
if (parsed.ok) return parsed.value;
return window.config?.bridge?.mtu ?? null;
}
function updateBridgeMtuState() {
const useXdpBridge = document.getElementById("useXdpBridge")?.checked ?? false;
const bridgeMtu = document.getElementById("bridgeMtu");
const bridgeMtuHelp = document.getElementById("bridgeMtuHelp");
if (!bridgeMtu || !bridgeMtuHelp) return;
const editingLocked = Boolean(currentInspection?.editing_locked);
bridgeMtu.disabled = useXdpBridge || editingLocked;
if (editingLocked) {
bridgeMtuHelp.textContent = MTU_LOCKED_HELP;
return;
}
if (useXdpBridge) {
clearMtuFieldError("bridgeMtu", "bridgeMtuError");
bridgeMtuHelp.textContent = XDP_MTU_HELP;
} else {
bridgeMtuHelp.textContent = BRIDGE_MTU_HELP;
}
}
function updateSingleInterfaceMtuState() {
const singleInterfaceMtu = document.getElementById("singleInterfaceMtu");
const singleInterfaceMtuHelp = document.getElementById("singleInterfaceMtuHelp");
if (!singleInterfaceMtu || !singleInterfaceMtuHelp) return;
if (currentInspection?.editing_locked) {
singleInterfaceMtuHelp.textContent = MTU_LOCKED_HELP;
} else {
singleInterfaceMtuHelp.textContent = SINGLE_INTERFACE_MTU_HELP;
}
}
function updateMtuState() {
updateBridgeMtuState();
updateSingleInterfaceMtuState();
}
function validateConfig() {
clearMtuErrors();
if (document.getElementById("bridgeMode").checked) {
const toInternet = document.getElementById("toInternet").value.trim();
const toNetwork = document.getElementById("toNetwork").value.trim();
@@ -83,6 +175,11 @@ function validateConfig() {
alert("Bridge mode requires two different interfaces");
return false;
}
if (document.getElementById("useXdpBridge").checked) {
clearMtuFieldError("bridgeMtu", "bridgeMtuError");
} else if (!validateMtuField("bridgeMtu", "bridgeMtuError", parseOptionalMtu)) {
return false;
}
} else if (document.getElementById("singleInterfaceMode").checked) {
const interfaceName = document.getElementById("interface").value.trim();
const internetVlan = parseInt(document.getElementById("internetVlan").value, 10);
@@ -99,6 +196,9 @@ function validateConfig() {
alert("Network VLAN must be between 1 and 4094");
return false;
}
if (!validateMtuField("singleInterfaceMtu", "singleInterfaceMtuError", parseOptionalMtu)) {
return false;
}
} else {
alert("Please select either bridge or single interface mode");
return false;
@@ -116,12 +216,14 @@ function buildCandidateConfig() {
use_xdp_bridge: document.getElementById("useXdpBridge").checked,
to_internet: document.getElementById("toInternet").value.trim(),
to_network: document.getElementById("toNetwork").value.trim(),
mtu: currentBridgeMtuValue(),
};
} else {
next.single_interface = {
interface: document.getElementById("interface").value.trim(),
internet_vlan: parseInt(document.getElementById("internetVlan").value, 10),
network_vlan: parseInt(document.getElementById("networkVlan").value, 10),
mtu: parseOptionalMtu(document.getElementById("singleInterfaceMtu").value).value,
};
}
@@ -247,15 +349,18 @@ function populateFormFromConfig(config) {
document.getElementById("useXdpBridge").checked = config.bridge.use_xdp_bridge ?? true;
document.getElementById("toInternet").value = config.bridge.to_internet ?? "";
document.getElementById("toNetwork").value = config.bridge.to_network ?? "";
document.getElementById("bridgeMtu").value = config.bridge.mtu ?? "";
} else if (config?.single_interface) {
document.getElementById("singleInterfaceMode").checked = true;
document.getElementById("interface").value = config.single_interface.interface ?? "";
document.getElementById("internetVlan").value = config.single_interface.internet_vlan ?? 2;
document.getElementById("networkVlan").value = config.single_interface.network_vlan ?? 3;
document.getElementById("singleInterfaceMtu").value = config.single_interface.mtu ?? "";
}
const event = new Event("change");
document.querySelector('input[name="networkMode"]:checked')?.dispatchEvent(event);
updateMtuState();
}
function loadDraft() {
@@ -590,9 +695,11 @@ function renderInspection(inspection) {
"useXdpBridge",
"toInternet",
"toNetwork",
"bridgeMtu",
"interface",
"internetVlan",
"networkVlan",
"singleInterfaceMtu",
"saveButton",
].forEach((id) => {
const element = document.getElementById(id);
@@ -603,6 +710,7 @@ function renderInspection(inspection) {
applyButton.disabled = !inspection?.can_apply;
adoptButton.disabled = !inspection?.can_adopt;
takeoverButton.disabled = !inspection?.can_take_over;
updateMtuState();
}
function renderHelperStatus(status) {
@@ -820,6 +928,7 @@ function retryShaping() {
}
function wireActions() {
applyMtuBounds();
wireReviewTabs();
[
"bridgeMode",
@@ -832,6 +941,21 @@ function wireActions() {
renderInterfaceSelectors();
});
});
document.getElementById("useXdpBridge").addEventListener("change", updateMtuState);
[
"bridgeMtu",
"singleInterfaceMtu",
].forEach((id) => {
document.getElementById(id).addEventListener("input", () => {
if (id === "bridgeMtu") {
const parsed = parseOptionalMtu(document.getElementById("bridgeMtu").value);
if (parsed.ok) clearMtuFieldError("bridgeMtu", "bridgeMtuError");
} else {
const parsed = parseOptionalMtu(document.getElementById("singleInterfaceMtu").value);
if (parsed.ok) clearMtuFieldError("singleInterfaceMtu", "singleInterfaceMtuError");
}
});
});
document.getElementById("saveButton").addEventListener("click", () => {
if (!validateConfig()) return;
saveDraft();
@@ -867,7 +991,7 @@ loadConfig((msg) => {
renderShapingStatus(payload.shaping_status || {});
renderInspection(payload.network_mode_inspection || {});
const draft = loadDraft();
const draft = hydrateDraftMtu(loadDraft(), window.config);
populateFormFromConfig(draft || window.config);
renderRecoveryPanel();
wireActions();
@@ -10,6 +10,7 @@ fi
echo "[test-node-manager] Running frontend contract tests..."
node --test "${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs"
node --test "${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs"
echo "[test-node-manager] Building bundles..."
"${SCRIPT_DIR}/esbuild.sh"
@@ -77,7 +77,7 @@ fn unauthorized() -> (StatusCode, Json<ApplyResponse>) {
}
fn helper_validation_error_response(message: String) -> (StatusCode, Json<ApplyResponse>) {
warn!("Bridge helper request failed: {message}");
warn!("Network-mode request failed: {message}");
(
StatusCode::BAD_REQUEST,
Json(ApplyResponse {
@@ -90,7 +90,7 @@ fn helper_validation_error_response(message: String) -> (StatusCode, Json<ApplyR
}
fn helper_internal_error_response(message: String) -> (StatusCode, Json<ApplyResponse>) {
error!("Bridge helper internal error: {message}");
error!("Network-mode internal error: {message}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApplyResponse {
@@ -102,11 +102,20 @@ fn helper_internal_error_response(message: String) -> (StatusCode, Json<ApplyRes
)
}
fn merge_network_mode(candidate: Config) -> Result<Config, StatusCode> {
let live = lqos_config::load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
#[derive(Debug, Clone, PartialEq, Eq)]
enum MergeNetworkModeError {
LoadLiveConfig,
InvalidCandidate(String),
}
fn merge_network_mode(candidate: Config) -> Result<Config, MergeNetworkModeError> {
let live = lqos_config::load_config().map_err(|_| MergeNetworkModeError::LoadLiveConfig)?;
let mut merged = (*live).clone();
merged.bridge = candidate.bridge;
merged.single_interface = candidate.single_interface;
merged
.validate()
.map_err(MergeNetworkModeError::InvalidCandidate)?;
Ok(merged)
}
@@ -156,13 +165,23 @@ pub async fn inspect(
State(state): State<NetworkModeApiState>,
Extension(login): Extension<LoginResult>,
Json(body): Json<NetworkModeInspectRequest>,
) -> Result<Json<NetworkModeInspection>, StatusCode> {
) -> Result<Json<NetworkModeInspection>, (StatusCode, Json<ApplyResponse>)> {
if login != LoginResult::Admin {
return Err(StatusCode::FORBIDDEN);
return Err(unauthorized());
}
let merged = merge_network_mode(body.config)?;
Ok(Json(run_network_mode_inspection(&state, merged).await?))
let merged = merge_network_mode(body.config).map_err(|error| match error {
MergeNetworkModeError::InvalidCandidate(message) => {
helper_validation_error_response(message)
}
MergeNetworkModeError::LoadLiveConfig => helper_internal_error_response(
"Unable to load the live LibreQoS configuration".to_string(),
),
})?;
run_network_mode_inspection(&state, merged)
.await
.map(Json)
.map_err(|_| helper_internal_error_response("Unable to inspect network mode".to_string()))
}
pub async fn apply(
@@ -178,16 +197,17 @@ pub async fn apply(
let username = get_username(&jar).await;
let merged = match merge_network_mode(body.config) {
Ok(config) => config,
Err(status) => {
return (
status,
Json(ApplyResponse {
ok: false,
message: "Unable to load the live LibreQoS configuration".to_string(),
operation: None,
last_backup_id: None,
}),
);
Err(error) => {
return match error {
MergeNetworkModeError::InvalidCandidate(message) => {
helper_validation_error_response(message)
}
MergeNetworkModeError::LoadLiveConfig => {
helper_internal_error_response(
"Unable to load the live LibreQoS configuration".to_string(),
)
}
};
}
};
let result = tokio::task::spawn_blocking({
@@ -212,7 +232,9 @@ pub async fn apply(
match result {
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
Err(err) => {
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
}
}
}
@@ -238,7 +260,9 @@ pub async fn confirm(
match result {
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
Err(err) => {
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
}
}
}
@@ -264,7 +288,9 @@ pub async fn revert(
match result {
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
Err(err) => {
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
}
}
}
@@ -290,7 +316,9 @@ pub async fn rollback(
match result {
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
Err(err) => {
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
}
}
}
@@ -314,6 +342,88 @@ pub async fn retry_shaping(
match result {
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
Ok(Err(err)) => helper_internal_error_response(err.to_string()),
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
Err(err) => {
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
}
}
}
#[cfg(test)]
mod tests {
use super::{MergeNetworkModeError, merge_network_mode};
use lqos_config::{BridgeConfig, Config, SingleInterfaceConfig};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn with_config_env<T>(test_fn: impl FnOnce() -> T) -> T {
let _guard = env_lock().lock().expect("network-mode env lock");
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let config_path = std::env::temp_dir().join(format!(
"lqosd-network-mode-test-{}.toml",
std::process::id()
));
let raw = include_str!("../../../../lqos_config/src/etc/v15/example.toml");
std::fs::write(&config_path, raw).expect("write config");
lqos_config::clear_cached_config();
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
}
let result = test_fn();
match old_lqos_config {
Some(value) => unsafe {
std::env::set_var("LQOS_CONFIG", value);
},
None => unsafe {
std::env::remove_var("LQOS_CONFIG");
},
}
lqos_config::clear_cached_config();
let _ = std::fs::remove_file(config_path);
result
}
#[test]
fn merge_network_mode_rejects_invalid_bridge_mtu() {
let mut candidate = Config::default();
candidate.bridge = Some(BridgeConfig {
mtu: Some(9217),
..BridgeConfig::default()
});
let error =
with_config_env(|| merge_network_mode(candidate).expect_err("invalid MTU should fail"));
assert_eq!(
error,
MergeNetworkModeError::InvalidCandidate(
"bridge.mtu must be between 576 and 9216".to_string()
)
);
}
#[test]
fn merge_network_mode_rejects_invalid_single_interface_mtu() {
let mut candidate = Config::default();
candidate.bridge = None;
candidate.single_interface = Some(SingleInterfaceConfig {
mtu: Some(575),
..SingleInterfaceConfig::default()
});
let error =
with_config_env(|| merge_network_mode(candidate).expect_err("invalid MTU should fail"));
assert_eq!(
error,
MergeNetworkModeError::InvalidCandidate(
"single_interface.mtu must be between 576 and 9216".to_string()
)
);
}
}
@@ -55,12 +55,12 @@
<section class="lqos-config-panel">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Bridge Mode Configuration</h6>
<div class="lqos-config-section-subtitle">Choose the two shaping interfaces for the managed Linux bridge flow, or leave XDP enabled if you are intentionally handling Netplan outside this page.</div>
<div class="lqos-config-section-subtitle">Choose the two shaping interfaces. Linux bridge mode can manage Netplan; XDP mode leaves Netplan alone.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useXdpBridge">
<input type="checkbox" class="form-check-input" id="useXdpBridge" aria-describedby="useXdpBridgeHelp">
<label class="form-check-label" for="useXdpBridge">Use XDP Bridge</label>
<div class="form-text">Use the XDP bridge instead of the Linux Bridge. This remains a manual/power-user netplan workflow.</div>
<div id="useXdpBridgeHelp" class="form-text">XDP does not write Netplan or apply Bridge MTU.</div>
</div>
<div class="mb-3">
@@ -78,6 +78,13 @@
</select>
<div class="form-text">Pick a different eligible interface for the LAN side.</div>
</div>
<div class="mb-2">
<label for="bridgeMtu" class="form-label">Bridge MTU</label>
<input type="number" class="form-control" id="bridgeMtu" aria-describedby="bridgeMtuHelp bridgeMtuError">
<div id="bridgeMtuHelp" class="form-text">Applies to the bridge members and br0.</div>
<div id="bridgeMtuError" class="invalid-feedback" role="alert"></div>
</div>
<div id="bridgeInterfaceHelp" class="small text-secondary"></div>
</div>
</section>
@@ -87,7 +94,7 @@
<section class="lqos-config-panel">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Single Interface Mode Configuration</h6>
<div class="lqos-config-section-subtitle">Pick the trunk interface and define the WAN and LAN VLAN IDs that LibreQoS should prepare in managed Netplan.</div>
<div class="lqos-config-section-subtitle">Pick the trunk interface and VLAN IDs for managed Netplan.</div>
<div class="mb-3">
<label for="interface" class="form-label">Interface Name</label>
@@ -108,6 +115,13 @@
<input type="number" class="form-control" id="networkVlan" min="1" max="4094">
<div class="form-text">VLAN ID for LAN traffic.</div>
</div>
<div class="mb-0 mt-3">
<label for="singleInterfaceMtu" class="form-label">Single Interface MTU</label>
<input type="number" class="form-control" id="singleInterfaceMtu" aria-describedby="singleInterfaceMtuHelp singleInterfaceMtuError">
<div id="singleInterfaceMtuHelp" class="form-text">Applies to the trunk interface.</div>
<div id="singleInterfaceMtuError" class="invalid-feedback" role="alert"></div>
</div>
<div id="singleInterfaceHelp" class="small text-secondary mt-2"></div>
</div>
</section>