mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 22:07:40 -05:00
changes
This commit is contained in:
1 parent
0523c2fc20
commit
6eac4c10d4
15 files changed
+697
-50
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user