mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 06:17:39 -05:00
Feat: add compatibility shim to first-run setup
This commit is contained in:
1 parent
f7d53d8cc6
commit
e939ff5a87
10 files changed
+477
-179
No files matched your search
@@ -2,21 +2,22 @@
|
||||
|
||||
## Elegir el Tipo de Puente
|
||||
|
||||
Hay dos opciones para que el puente pase datos entre las dos interfaces:
|
||||
LibreQoS admite estas rutas entre las dos interfaces de regulación:
|
||||
|
||||
- Opción A: Puente Regular de Linux (Recomendado)
|
||||
- Opción B: Puente Bifrost Acelerado con XDP
|
||||
- Opción C: Capa de compatibilidad para interfaces con bonding y controladores que no pueden alojar XDP
|
||||
|
||||
El puente regular de Linux es recomendado para la mayoría de las instalaciones. El puente de Linux continúa transfiriendo datos incluso si el servicio lqosd entra en un estado fallido, lo que lo hace una opción generalmente más segura para escenarios donde no hay una ruta de respaldo disponible. Funciona mejor con tarjetas de red Nvidia/Mellanox como las de la serie ConnectX-5 (que ofrecen un rendimiento superior en puentes) y configuraciones de máquinas virtuales con NICs virtualizadas. El puente Bifrost con XDP está recomendado para tarjetas de red Intel de 40G–100G que soportan XDP.
|
||||
|
||||
A continuación, se encuentran las instrucciones para configurar Netplan, ya sea usando el puente de Linux o el puente Bifrost con XDP:
|
||||
Los siguientes ejemplos de Netplan abarcan el puente normal de Linux y la ruta directa de Bifrost con XDP. La capa de compatibilidad se describe por separado más adelante.
|
||||
|
||||
```{note}
|
||||
La página Network Mode en la interfaz web de LibreQoS ahora inspecciona los archivos actuales de Netplan, ofrece en menús desplegables las interfaces elegibles que no forman parte de la ruta de gestión, prepara cambios administrados para `libreqos.yaml` en los modos puente de Linux y de interfaz única, los aplica con una ventana temporizada de reversión manejada por LibreQoS y le permite confirmar o revertir el cambio pendiente. También puede restaurar desde esa página la copia de seguridad administrada anterior. El modo puente XDP sigue siendo un flujo manual de Netplan.
|
||||
```
|
||||
|
||||
```{note}
|
||||
La configuración inicial actual solo ofrece Puente Linux e Interfaz Única para instalaciones nuevas. Si LibreQoS detecta un despliegue XDP existente, la configuración preserva ese modo XDP heredado y le avisa antes de migrar fuera de él.
|
||||
La configuración inicial ofrece Puente Linux, Capa de compatibilidad de interfaces e Interfaz Única. Use la capa solo para interfaces con bonding o controladores que no puedan alojar XDP de LibreQoS directamente. Si LibreQoS detecta un despliegue existente con XDP directo, también conserva ese modo heredado y le avisa antes de migrar fuera de él.
|
||||
```
|
||||
|
||||
```{note}
|
||||
@@ -106,7 +107,7 @@ Use la capa de compatibilidad solo cuando LibreQoS no pueda adjuntar XDP directa
|
||||
|
||||
La capa conecta cada interfaz física a una interfaz veth multiqueue mediante un pequeño puente de Linux. LibreQoS adjunta a las interfaces veth su ruta existente de XDP y colas. Esto consume más CPU, pero mantiene activas en las interfaces físicas las descargas de sumas de comprobación, segmentación y VLAN. LibreQoS sigue aplicando los valores configurados de moderación de interrupciones cuando el controlador físico los acepta.
|
||||
|
||||
Actívela en la página `Bridge & Interface Mode`, o configure:
|
||||
Seleccione `Interface Compatibility Shim` durante la configuración inicial, actívela en la página `Bridge & Interface Mode`, o configure:
|
||||
|
||||
```toml
|
||||
[bridge]
|
||||
|
||||
+5
-4
@@ -2,21 +2,22 @@
|
||||
|
||||
## Choose Bridge Type
|
||||
|
||||
There are two options for the bridge to pass data through your two interfaces:
|
||||
LibreQoS supports these paths between the two shaping interfaces:
|
||||
|
||||
- Option A: Regular Linux Bridge (Recommended)
|
||||
- Option B: Bifrost XDP-Accelerated Bridge
|
||||
- Option C: Interface Compatibility Shim for bonds and drivers that cannot host XDP
|
||||
|
||||
The regular Linux bridge is recommended for most installations. The Linux Bridge continues to move data even if the lqosd service is in a failed state, making this a generally safer option in scenarios where a backup route is not in place. It works best with Nvidia/Mellanox NICs such as the ConnectX-5 series (which have superior bridge performance), and VM setups using virtualized NICs. The Bifrost XDP Bridge is recommended for 40G-100G Intel NICs with XDP support.
|
||||
|
||||
Below are the instructions to configure Netplan, whether using the Linux Bridge or Bifrost XDP bridge:
|
||||
The Netplan examples below cover the regular Linux bridge and direct Bifrost XDP paths. The compatibility shim is described separately afterward.
|
||||
|
||||
```{note}
|
||||
The Network Mode page in the LibreQoS web UI now inspects the current Netplan files, offers eligible non-management interfaces in dropdowns, stages managed `libreqos.yaml` changes for Linux bridge and single-interface modes, applies them with a timed LibreQoS rollback window, and lets you confirm or revert the pending change. You can also restore the previous managed backup from that page. XDP bridge mode remains a manual Netplan workflow.
|
||||
```
|
||||
|
||||
```{note}
|
||||
Current first-run setup only offers Linux Bridge and Single Interface for new installs. If LibreQoS detects an existing XDP deployment, setup preserves that legacy XDP mode and warns before you migrate away from it.
|
||||
First-run setup offers Linux Bridge, Interface Compatibility Shim, and Single Interface. Use the shim only for bonds or drivers that cannot host LibreQoS XDP directly. If LibreQoS detects an existing direct-XDP deployment, setup also preserves that legacy mode and warns before you migrate away from it.
|
||||
```
|
||||
|
||||
```{note}
|
||||
@@ -106,7 +107,7 @@ Use the interface compatibility shim only when LibreQoS cannot attach XDP direct
|
||||
|
||||
The shim connects each physical interface to a multiqueue veth through a small Linux bridge. LibreQoS attaches its existing XDP and queueing path to the veth interfaces. This adds CPU overhead, but leaves checksum, segmentation, and VLAN offloads enabled on the physical interfaces. LibreQoS still applies the configured interrupt-coalescing values where the physical driver accepts them.
|
||||
|
||||
Enable it on the `Bridge & Interface Mode` page, or set:
|
||||
Choose `Interface Compatibility Shim` during first-run setup, enable it on the `Bridge & Interface Mode` page, or set:
|
||||
|
||||
```toml
|
||||
[bridge]
|
||||
|
||||
@@ -10,7 +10,7 @@ The LibreQoS setup tool guides users through the essential configuration steps r
|
||||
|
||||
The tool assists in configuring the following components:
|
||||
|
||||
- **Bridge Mode**: Select between Linux, XDP, or Single bridge modes to match your deployment scenario.
|
||||
- **Bridge Mode**: Select Linux Bridge, Interface Compatibility Shim, or Single Interface. Existing direct-XDP installs can preserve their legacy mode.
|
||||
- **Network Interfaces**: Choose and configure the network interfaces that LibreQoS will manage.
|
||||
- **Bandwidth Settings**: Set bandwidth limits and parameters for your network environment.
|
||||
- **IP Ranges**: Define the IP address ranges that LibreQoS should monitor and shape.
|
||||
|
||||
@@ -4,11 +4,11 @@ use cursive::{
|
||||
views::{Dialog, LinearLayout, RadioGroup, TextView},
|
||||
};
|
||||
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG, existing_config_uses_xdp};
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG, existing_config_uses_direct_xdp};
|
||||
|
||||
pub fn bridge_mode(s: &mut Cursive) {
|
||||
let current_mode = CURRENT_CONFIG.lock().bridge_mode;
|
||||
let show_legacy_xdp = existing_config_uses_xdp() || current_mode == BridgeMode::XDP;
|
||||
let show_legacy_xdp = existing_config_uses_direct_xdp() || current_mode == BridgeMode::XDP;
|
||||
|
||||
// create the group and buttons
|
||||
let mut group = RadioGroup::new().on_change(|_s, mode| {
|
||||
@@ -26,6 +26,10 @@ pub fn bridge_mode(s: &mut Cursive) {
|
||||
"Legacy XDP Bridge (existing installs only - keep only if you already run it)",
|
||||
)
|
||||
});
|
||||
let mut shim_btn = group.button(
|
||||
BridgeMode::CompatibilityShim,
|
||||
"Interface Compatibility Shim (2 interfaces) - for bonds and drivers that cannot host XDP",
|
||||
);
|
||||
let mut single_btn = group.button(BridgeMode::Single, "Single Interface (1 interface)");
|
||||
|
||||
// mark the one we want as selected
|
||||
@@ -38,6 +42,9 @@ pub fn bridge_mode(s: &mut Cursive) {
|
||||
button.select();
|
||||
}
|
||||
}
|
||||
BridgeMode::CompatibilityShim => {
|
||||
shim_btn.select();
|
||||
}
|
||||
_ => {
|
||||
linux_btn.select();
|
||||
}
|
||||
@@ -53,6 +60,7 @@ pub fn bridge_mode(s: &mut Cursive) {
|
||||
));
|
||||
layout.add_child(button);
|
||||
}
|
||||
layout.add_child(shim_btn);
|
||||
layout.add_child(single_btn);
|
||||
|
||||
s.add_layer(
|
||||
|
||||
@@ -12,9 +12,16 @@ pub static CURRENT_CONFIG: Lazy<Mutex<ConfigBuilder>> =
|
||||
pub enum BridgeMode {
|
||||
Linux,
|
||||
XDP,
|
||||
CompatibilityShim,
|
||||
Single,
|
||||
}
|
||||
|
||||
impl BridgeMode {
|
||||
pub(crate) const fn uses_netplan_helper(self) -> bool {
|
||||
matches!(self, Self::Linux | Self::Single)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConfigBuilder {
|
||||
pub bridge_mode: BridgeMode,
|
||||
@@ -38,7 +45,11 @@ impl ConfigBuilder {
|
||||
let mut internet_vlan = 0;
|
||||
let mut network_vlan = 0;
|
||||
let mode = if let Some(bridge) = &cfg.bridge {
|
||||
if bridge.use_xdp_bridge {
|
||||
if bridge.compatibility_shim_enabled() {
|
||||
to_internet = bridge.to_internet.clone();
|
||||
to_network = bridge.to_network.clone();
|
||||
BridgeMode::CompatibilityShim
|
||||
} else if bridge.use_xdp_bridge {
|
||||
to_internet = bridge.to_internet.clone();
|
||||
to_network = bridge.to_network.clone();
|
||||
BridgeMode::XDP
|
||||
@@ -110,16 +121,21 @@ pub fn existing_config_load_error() -> Option<String> {
|
||||
existing_config_load_error_for_path(¤t_config_path())
|
||||
}
|
||||
|
||||
pub fn existing_config_uses_xdp() -> bool {
|
||||
pub fn existing_config_uses_direct_xdp() -> bool {
|
||||
lqos_config::load_config()
|
||||
.ok()
|
||||
.and_then(|config| config.bridge.as_ref().map(|bridge| bridge.use_xdp_bridge))
|
||||
.and_then(|config| {
|
||||
config
|
||||
.bridge
|
||||
.as_ref()
|
||||
.map(|bridge| bridge.use_xdp_bridge && !bridge.compatibility_shim_enabled())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ConfigBuilder;
|
||||
use super::{BridgeMode, ConfigBuilder, existing_config_uses_direct_xdp};
|
||||
use crate::test_support::ConfigEnvGuard;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -143,4 +159,61 @@ mod tests {
|
||||
|
||||
fs::remove_file(path).expect("remove temp config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_config_loads_as_its_own_setup_mode() {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock before epoch")
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"libreqos-setup-shim-config-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&root).expect("create shim config directory");
|
||||
let path = root.join("lqos.conf");
|
||||
let _env_guard = ConfigEnvGuard::set_lqos_config(&path);
|
||||
let mut config = lqos_config::Config {
|
||||
lqos_directory: root.display().to_string(),
|
||||
state_directory: Some(root.join("state").display().to_string()),
|
||||
bridge: Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: true,
|
||||
compatibility_shim: true,
|
||||
to_internet: "bond0".to_string(),
|
||||
to_network: "bond1".to_string(),
|
||||
..lqos_config::BridgeConfig::default()
|
||||
}),
|
||||
..lqos_config::Config::default()
|
||||
};
|
||||
let serialized = toml::to_string_pretty(&config).expect("serialize shim config");
|
||||
fs::write(&path, serialized).expect("write shim config");
|
||||
lqos_config::clear_cached_config();
|
||||
|
||||
let builder = ConfigBuilder::new();
|
||||
|
||||
assert_eq!(builder.bridge_mode, BridgeMode::CompatibilityShim);
|
||||
assert_eq!(builder.to_internet, "bond0");
|
||||
assert_eq!(builder.to_network, "bond1");
|
||||
assert!(!existing_config_uses_direct_xdp());
|
||||
|
||||
config
|
||||
.bridge
|
||||
.as_mut()
|
||||
.expect("bridge config")
|
||||
.compatibility_shim = false;
|
||||
let serialized = toml::to_string_pretty(&config).expect("serialize direct XDP config");
|
||||
fs::write(&path, serialized).expect("write direct XDP config");
|
||||
lqos_config::clear_cached_config();
|
||||
assert!(existing_config_uses_direct_xdp());
|
||||
|
||||
fs::remove_dir_all(root).expect("remove temp config directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_updates_config_without_managing_netplan() {
|
||||
assert!(!BridgeMode::CompatibilityShim.uses_netplan_helper());
|
||||
assert!(!BridgeMode::XDP.uses_netplan_helper());
|
||||
assert!(BridgeMode::Linux.uses_netplan_helper());
|
||||
assert!(BridgeMode::Single.uses_netplan_helper());
|
||||
}
|
||||
}
|
||||
@@ -9,73 +9,98 @@ use cursive::{
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InterfaceOption {
|
||||
pub name: String,
|
||||
pub label: String,
|
||||
pub(crate) struct InterfaceOption {
|
||||
pub(crate) name: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) bridge_eligible: bool,
|
||||
pub(crate) compatibility_shim_eligible: bool,
|
||||
pub(crate) single_interface_eligible: bool,
|
||||
}
|
||||
|
||||
pub fn get_interfaces() -> anyhow::Result<Vec<String>> {
|
||||
Ok(get_interface_options()?
|
||||
.into_iter()
|
||||
.map(|iface| iface.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_interface_options() -> anyhow::Result<Vec<InterfaceOption>> {
|
||||
let mut interfaces = nix::ifaddrs::getifaddrs()?
|
||||
.filter(|iface| interface_supports_lqos(&iface.interface_name).is_ok())
|
||||
.map(|iface| iface.interface_name)
|
||||
.collect::<Vec<_>>();
|
||||
interfaces.sort();
|
||||
interfaces.dedup();
|
||||
Ok(interfaces
|
||||
.into_iter()
|
||||
.map(|name| InterfaceOption {
|
||||
label: interface_label(&name),
|
||||
name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn interface_supports_lqos(interface: &str) -> anyhow::Result<()> {
|
||||
let path = format!("/sys/class/net/{interface}/queues/");
|
||||
let sys_path = Path::new(&path);
|
||||
if !sys_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"/sys/class/net/{interface}/queues/ does not exist. Does this card only support one queue (not supported)?"
|
||||
));
|
||||
}
|
||||
|
||||
let mut counts = (0, 0);
|
||||
let paths = std::fs::read_dir(sys_path)?;
|
||||
for path in paths {
|
||||
if let Ok(path) = &path
|
||||
&& path.path().is_dir()
|
||||
&& let Some(filename) = path.path().file_name()
|
||||
&& let Some(filename) = filename.to_str()
|
||||
{
|
||||
if filename.starts_with("rx-") {
|
||||
counts.0 += 1;
|
||||
} else if filename.starts_with("tx-") {
|
||||
counts.1 += 1;
|
||||
}
|
||||
impl InterfaceOption {
|
||||
pub(crate) fn is_eligible_for(&self, mode: BridgeMode) -> bool {
|
||||
match mode {
|
||||
BridgeMode::Linux | BridgeMode::XDP => self.bridge_eligible,
|
||||
BridgeMode::CompatibilityShim => self.compatibility_shim_eligible,
|
||||
BridgeMode::Single => self.single_interface_eligible,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if counts.0 == 0 || counts.1 == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Interface {} does not have both RX and TX queues.",
|
||||
interface
|
||||
));
|
||||
}
|
||||
if counts.0 == 1 || counts.1 == 1 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Interface {} only has one RX or TX queue. This is not supported.",
|
||||
interface
|
||||
));
|
||||
}
|
||||
pub(crate) fn get_interface_options() -> Vec<InterfaceOption> {
|
||||
let inspection = lqos_netplan_helper::inspect_network_mode(&lqos_config::Config::default());
|
||||
inspection
|
||||
.interface_candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| {
|
||||
candidate.bridge_eligible
|
||||
|| candidate.compatibility_shim_eligible
|
||||
|| candidate.single_interface_eligible
|
||||
})
|
||||
.map(|candidate| InterfaceOption {
|
||||
label: interface_label(&candidate.name),
|
||||
name: candidate.name,
|
||||
bridge_eligible: candidate.bridge_eligible,
|
||||
compatibility_shim_eligible: candidate.compatibility_shim_eligible,
|
||||
single_interface_eligible: candidate.single_interface_eligible,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
fn eligible_interface_options(mode: BridgeMode) -> Vec<InterfaceOption> {
|
||||
get_interface_options()
|
||||
.into_iter()
|
||||
.filter(|interface| interface.is_eligible_for(mode))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn validate_mode_interfaces(
|
||||
mode: BridgeMode,
|
||||
to_internet: &str,
|
||||
to_network: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
validate_mode_interfaces_with_options(&get_interface_options(), mode, to_internet, to_network)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_mode_interfaces_with_options(
|
||||
options: &[InterfaceOption],
|
||||
mode: BridgeMode,
|
||||
to_internet: &str,
|
||||
to_network: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if mode == BridgeMode::Single {
|
||||
if to_internet.is_empty() {
|
||||
anyhow::bail!("A shaping interface is required.");
|
||||
}
|
||||
return validate_interface_selection(options, to_internet, mode, "Shaping");
|
||||
}
|
||||
if to_internet.is_empty() {
|
||||
anyhow::bail!("An internet-facing interface is required.");
|
||||
}
|
||||
if to_network.is_empty() {
|
||||
anyhow::bail!("A network-facing interface is required.");
|
||||
}
|
||||
if to_internet == to_network {
|
||||
anyhow::bail!("Internet and network interfaces must be different.");
|
||||
}
|
||||
validate_interface_selection(options, to_internet, mode, "Internet-facing")?;
|
||||
validate_interface_selection(options, to_network, mode, "Network-facing")
|
||||
}
|
||||
|
||||
fn validate_interface_selection(
|
||||
options: &[InterfaceOption],
|
||||
interface: &str,
|
||||
mode: BridgeMode,
|
||||
role: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if options
|
||||
.iter()
|
||||
.any(|option| option.name == interface && option.is_eligible_for(mode))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{role} interface {interface} is not eligible for the selected mode.")
|
||||
}
|
||||
}
|
||||
|
||||
fn interface_label(interface: &str) -> String {
|
||||
@@ -186,8 +211,8 @@ fn build_interface_list(
|
||||
fn build_layout() -> LinearLayout {
|
||||
let bridge_mode = CURRENT_CONFIG.lock().bridge_mode;
|
||||
match bridge_mode {
|
||||
BridgeMode::Linux | BridgeMode::XDP => {
|
||||
let interfaces = get_interface_options().expect("Failed to get interfaces");
|
||||
BridgeMode::Linux | BridgeMode::XDP | BridgeMode::CompatibilityShim => {
|
||||
let interfaces = eligible_interface_options(bridge_mode);
|
||||
|
||||
// If the configuration has empty interface fields, set them to the first available interface
|
||||
{
|
||||
@@ -241,7 +266,7 @@ fn build_layout() -> LinearLayout {
|
||||
.child(network_layout)
|
||||
}
|
||||
BridgeMode::Single => {
|
||||
let interfaces = get_interface_options().expect("Failed to get interfaces");
|
||||
let interfaces = eligible_interface_options(bridge_mode);
|
||||
|
||||
// If the configuration has empty interface field, set it to the first available interface
|
||||
{
|
||||
@@ -318,7 +343,6 @@ fn build_layout() -> LinearLayout {
|
||||
}
|
||||
|
||||
pub fn interface_menu(s: &mut Cursive) {
|
||||
get_interfaces().expect("Failed to get interfaces");
|
||||
s.add_layer(
|
||||
Dialog::around(build_layout())
|
||||
.title("Select Interfaces")
|
||||
@@ -329,3 +353,50 @@ pub fn interface_menu(s: &mut Cursive) {
|
||||
.full_screen(),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InterfaceOption, validate_mode_interfaces_with_options};
|
||||
use crate::config_builder::BridgeMode;
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_uses_its_relaxed_interface_eligibility() {
|
||||
let bond = InterfaceOption {
|
||||
name: "bond0".to_string(),
|
||||
label: "bond0".to_string(),
|
||||
bridge_eligible: false,
|
||||
compatibility_shim_eligible: true,
|
||||
single_interface_eligible: false,
|
||||
};
|
||||
|
||||
assert!(bond.is_eligible_for(BridgeMode::CompatibilityShim));
|
||||
assert!(!bond.is_eligible_for(BridgeMode::Linux));
|
||||
assert!(!bond.is_eligible_for(BridgeMode::XDP));
|
||||
assert!(!bond.is_eligible_for(BridgeMode::Single));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_rejects_stale_ineligible_selection() {
|
||||
let options = [InterfaceOption {
|
||||
name: "bond0".to_string(),
|
||||
label: "bond0".to_string(),
|
||||
bridge_eligible: false,
|
||||
compatibility_shim_eligible: true,
|
||||
single_interface_eligible: false,
|
||||
}];
|
||||
|
||||
let error = validate_mode_interfaces_with_options(
|
||||
&options,
|
||||
BridgeMode::CompatibilityShim,
|
||||
"bond0",
|
||||
"stale0",
|
||||
)
|
||||
.expect_err("reject stale interface");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Network-facing interface stale0")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,14 @@ use cursive::views::{Dialog, LinearLayout, TextView};
|
||||
use crate::interfaces;
|
||||
|
||||
pub fn preflight() {
|
||||
let interfaces = interfaces::get_interfaces().unwrap();
|
||||
if interfaces.is_empty() {
|
||||
if interfaces::get_interface_options().is_empty() {
|
||||
let mut ui = cursive::default();
|
||||
ui.add_layer(
|
||||
Dialog::new()
|
||||
.title("No Compatible Network Interfaces Found")
|
||||
.content(LinearLayout::vertical()
|
||||
.child(TextView::new("LQOS requires at least one compatible network interface."))
|
||||
.child(TextView::new("Please ensure that your system has network interfaces that support XDP and multiple queues."))
|
||||
.child(TextView::new("Please ensure that your system has a supported shaping interface. The compatibility shim can use bonded interfaces that cannot host XDP directly."))
|
||||
.child(TextView::new("For more information, please refer to the documentation."))
|
||||
.child(TextView::new("https://libreqos.readthedocs.io/en/latest/docs/v2.0/requirements.html."))
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Shared setup commit/apply helpers used by both Cursive and the setup WebUI.
|
||||
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG, existing_config_load_error};
|
||||
use crate::{
|
||||
config_builder::{BridgeMode, CURRENT_CONFIG, existing_config_load_error},
|
||||
interfaces,
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest};
|
||||
use lqos_netplan_helper::transaction::{
|
||||
@@ -43,10 +46,6 @@ 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_compatibility_shim = config
|
||||
.bridge
|
||||
.as_ref()
|
||||
.is_some_and(|bridge| bridge.compatibility_shim_enabled());
|
||||
let existing_single_interface_mtu = config
|
||||
.single_interface
|
||||
.as_ref()
|
||||
@@ -58,24 +57,16 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
|
||||
config.queues.generated_pn_download_mbps = new_config.mbps_to_internet;
|
||||
config.queues.generated_pn_upload_mbps = new_config.mbps_to_network;
|
||||
match new_config.bridge_mode {
|
||||
BridgeMode::Linux => {
|
||||
mode @ (BridgeMode::Linux | BridgeMode::XDP | BridgeMode::CompatibilityShim) => {
|
||||
let use_xdp_bridge = !matches!(mode, BridgeMode::Linux);
|
||||
let compatibility_shim = matches!(mode, BridgeMode::CompatibilityShim);
|
||||
config.single_interface = None;
|
||||
config.bridge = Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: false,
|
||||
use_xdp_bridge,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
mtu: existing_bridge_mtu,
|
||||
compatibility_shim: false,
|
||||
});
|
||||
}
|
||||
BridgeMode::XDP => {
|
||||
config.single_interface = None;
|
||||
config.bridge = Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: true,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
mtu: existing_bridge_mtu,
|
||||
compatibility_shim: existing_compatibility_shim,
|
||||
compatibility_shim,
|
||||
});
|
||||
}
|
||||
BridgeMode::Single => {
|
||||
@@ -130,10 +121,20 @@ pub(crate) fn prepare_commit() -> Result<CommitOutcome> {
|
||||
bail!("Setup requires at least one admin user before configuration can be committed.");
|
||||
}
|
||||
|
||||
let (mode, to_internet, to_network) = {
|
||||
let config = CURRENT_CONFIG.lock();
|
||||
(
|
||||
config.bridge_mode,
|
||||
config.to_internet.clone(),
|
||||
config.to_network.clone(),
|
||||
)
|
||||
};
|
||||
interfaces::validate_mode_interfaces(mode, &to_internet, &to_network)?;
|
||||
|
||||
let mut event_log = Vec::new();
|
||||
let existing_config = load_existing_or_default(&mut event_log)?;
|
||||
let config = build_candidate_config(Some(existing_config));
|
||||
let using_helper = !matches!(CURRENT_CONFIG.lock().bridge_mode, BridgeMode::XDP);
|
||||
let using_helper = mode.uses_netplan_helper();
|
||||
|
||||
if !using_helper {
|
||||
lqos_config::update_config(&config)?;
|
||||
@@ -261,7 +262,7 @@ fn load_existing_or_default(event_log: &mut Vec<String>) -> Result<lqos_config::
|
||||
mod tests {
|
||||
use super::{build_candidate_config, load_existing_or_default};
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG};
|
||||
use crate::test_support::ConfigEnvGuard;
|
||||
use crate::test_support::{ConfigEnvGuard, lock_current_config};
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -287,6 +288,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_candidate_config_preserves_existing_mtu_for_same_mode() {
|
||||
let _test_guard = lock_current_config();
|
||||
let previous_builder = {
|
||||
let mut builder = CURRENT_CONFIG.lock();
|
||||
let previous = builder.clone();
|
||||
@@ -313,25 +315,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidate_config_keeps_compatibility_shim_only_in_xdp_mode() {
|
||||
fn build_candidate_config_sets_compatibility_shim_only_in_shim_mode() {
|
||||
let _test_guard = lock_current_config();
|
||||
let previous_builder = {
|
||||
let mut builder = CURRENT_CONFIG.lock();
|
||||
let previous = builder.clone();
|
||||
builder.bridge_mode = BridgeMode::XDP;
|
||||
builder.bridge_mode = BridgeMode::CompatibilityShim;
|
||||
builder.to_internet = "bond0".to_string();
|
||||
builder.to_network = "bond1".to_string();
|
||||
previous
|
||||
};
|
||||
|
||||
let existing = lqos_config::Config {
|
||||
bridge: Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: true,
|
||||
compatibility_shim: true,
|
||||
..lqos_config::BridgeConfig::default()
|
||||
}),
|
||||
..lqos_config::Config::default()
|
||||
};
|
||||
let candidate = build_candidate_config(Some(existing));
|
||||
let candidate = build_candidate_config(None);
|
||||
|
||||
assert!(
|
||||
candidate
|
||||
@@ -340,6 +335,15 @@ mod tests {
|
||||
.is_some_and(lqos_config::BridgeConfig::compatibility_shim_enabled)
|
||||
);
|
||||
|
||||
CURRENT_CONFIG.lock().bridge_mode = BridgeMode::XDP;
|
||||
let candidate = build_candidate_config(Some(candidate));
|
||||
assert!(
|
||||
candidate
|
||||
.bridge
|
||||
.as_ref()
|
||||
.is_some_and(|bridge| bridge.use_xdp_bridge && !bridge.compatibility_shim_enabled())
|
||||
);
|
||||
|
||||
CURRENT_CONFIG.lock().bridge_mode = BridgeMode::Linux;
|
||||
let candidate = build_candidate_config(Some(candidate));
|
||||
assert!(
|
||||
|
||||
@@ -2,6 +2,11 @@ use once_cell::sync::Lazy;
|
||||
use parking_lot::{Mutex, MutexGuard};
|
||||
|
||||
static CONFIG_ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
static CURRENT_CONFIG_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
pub(crate) fn lock_current_config() -> MutexGuard<'static, ()> {
|
||||
CURRENT_CONFIG_TEST_LOCK.lock()
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigEnvGuard {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
|
||||
+201
-65
@@ -1,7 +1,7 @@
|
||||
//! Minimal setup-only web server for first-run LibreQoS configuration.
|
||||
|
||||
use crate::{
|
||||
config_builder::{BridgeMode, CURRENT_CONFIG, existing_config_uses_xdp},
|
||||
config_builder::{BridgeMode, CURRENT_CONFIG, existing_config_uses_direct_xdp},
|
||||
interfaces, service_handoff,
|
||||
setup_actions::{self, CommitOutcome},
|
||||
};
|
||||
@@ -256,14 +256,15 @@ fn render_setup_page(
|
||||
let snapshot = bootstrap::status_snapshot()?;
|
||||
let token = token.ok_or_else(|| anyhow::anyhow!("Setup requires a valid tokenized URL."))?;
|
||||
ensure_valid_setup_token(token)?;
|
||||
let interface_options = interfaces::get_interface_options().unwrap_or_default();
|
||||
let interface_options = interfaces::get_interface_options();
|
||||
let config = CURRENT_CONFIG.lock().clone();
|
||||
let mode_label = match config.bridge_mode {
|
||||
BridgeMode::Single => "single",
|
||||
BridgeMode::XDP => "xdp",
|
||||
BridgeMode::CompatibilityShim => "shim",
|
||||
BridgeMode::Linux => "linux",
|
||||
};
|
||||
let persisted_xdp = existing_config_uses_xdp();
|
||||
let persisted_xdp = existing_config_uses_direct_xdp();
|
||||
|
||||
let mut html = page_shell("LibreQoS Setup");
|
||||
html.push_str("<main class=\"setup-shell\">");
|
||||
@@ -304,7 +305,7 @@ fn render_setup_page(
|
||||
if config.bridge_mode == BridgeMode::XDP {
|
||||
html.push_str("<p class=\"notice\">This system already uses legacy XDP bridge mode. LibreQoS no longer recommends XDP for new installs, but this page will preserve it if you leave the mode unchanged.</p>");
|
||||
} else {
|
||||
html.push_str("<p class=\"notice\">This system previously used legacy XDP bridge mode. Saving with Linux Bridge or Single Interface will migrate the node away from XDP.</p>");
|
||||
html.push_str("<p class=\"notice\">This system previously used legacy XDP bridge mode. Saving with Linux Bridge, Compatibility Shim, or Single Interface will migrate the node away from direct XDP.</p>");
|
||||
}
|
||||
html.push_str("<p class=\"muted\">Use Linux Bridge for long-term maintenance unless you intentionally need to keep the existing XDP deployment.</p></section>");
|
||||
}
|
||||
@@ -365,45 +366,36 @@ fn render_setup_page(
|
||||
selected(mode_label == "xdp")
|
||||
));
|
||||
}
|
||||
html.push_str(&format!(
|
||||
"<option value=\"shim\"{}>Interface Compatibility Shim</option>",
|
||||
selected(mode_label == "shim")
|
||||
));
|
||||
html.push_str(&format!(
|
||||
"<option value=\"single\"{}>Single Interface</option>",
|
||||
selected(mode_label == "single")
|
||||
));
|
||||
html.push_str("</select></label>");
|
||||
html.push_str("<div class=\"mode-section\" data-mode-section=\"bridge\">");
|
||||
html.push_str("<p class=\"muted\">Linux Bridge is recommended for most installs. Select the WAN-facing and subscriber-facing interfaces below. Existing XDP installs use the same interface pair if you choose to preserve XDP.</p>");
|
||||
html.push_str("<label>To Internet<select name=\"to_internet\">");
|
||||
html.push_str("<p class=\"muted\">Linux Bridge is recommended for most installs. Choose the compatibility shim only for bonded interfaces or drivers that cannot host LibreQoS XDP directly.</p>");
|
||||
html.push_str("<label>To Internet<select name=\"to_internet\" aria-describedby=\"interfaceValidationMessage\">");
|
||||
html.push_str("<option value=\"\">Select an eligible interface</option>");
|
||||
for iface in &interface_options {
|
||||
html.push_str(&format!(
|
||||
"<option value=\"{}\"{}>{}</option>",
|
||||
escape_html(&iface.name),
|
||||
selected(config.to_internet == iface.name),
|
||||
escape_html(&iface.label)
|
||||
));
|
||||
html.push_str(&interface_option_html(iface, &config.to_internet));
|
||||
}
|
||||
html.push_str("</select></label>");
|
||||
html.push_str("<label>To Network<select name=\"to_network\">");
|
||||
html.push_str("<label>To Network<select name=\"to_network\" aria-describedby=\"interfaceValidationMessage\">");
|
||||
html.push_str("<option value=\"\">Select an eligible interface</option>");
|
||||
for iface in &interface_options {
|
||||
html.push_str(&format!(
|
||||
"<option value=\"{}\"{}>{}</option>",
|
||||
escape_html(&iface.name),
|
||||
selected(config.to_network == iface.name),
|
||||
escape_html(&iface.label)
|
||||
));
|
||||
html.push_str(&interface_option_html(iface, &config.to_network));
|
||||
}
|
||||
html.push_str("</select></label>");
|
||||
html.push_str("<p id=\"interfaceValidationMessage\" class=\"validation-message\" hidden>Internet and network interfaces must be different.</p>");
|
||||
html.push_str("</div>");
|
||||
html.push_str("<div class=\"mode-section\" data-mode-section=\"single\">");
|
||||
html.push_str("<p class=\"muted\">Use this when a single interface carries both internet and subscriber traffic with optional VLAN tags.</p>");
|
||||
html.push_str("<label>Interface<select name=\"single_interface\">");
|
||||
html.push_str("<label>Interface<select name=\"single_interface\" aria-describedby=\"interfaceValidationMessage\">");
|
||||
html.push_str("<option value=\"\">Select an eligible interface</option>");
|
||||
for iface in &interface_options {
|
||||
html.push_str(&format!(
|
||||
"<option value=\"{}\"{}>{}</option>",
|
||||
escape_html(&iface.name),
|
||||
selected(config.to_internet == iface.name),
|
||||
escape_html(&iface.label)
|
||||
));
|
||||
html.push_str(&interface_option_html(iface, &config.to_internet));
|
||||
}
|
||||
html.push_str("</select></label>");
|
||||
html.push_str(&format!(
|
||||
@@ -415,6 +407,7 @@ fn render_setup_page(
|
||||
config.network_vlan
|
||||
));
|
||||
html.push_str("</div>");
|
||||
html.push_str("<p id=\"interfaceValidationMessage\" class=\"validation-message\" aria-live=\"polite\" hidden></p>");
|
||||
html.push_str(&format!(
|
||||
"<label>Allowed Subnets<textarea name=\"allow_subnets\" rows=\"6\">{}</textarea></label>",
|
||||
escape_html(&config.allow_subnets.join("\n"))
|
||||
@@ -443,6 +436,14 @@ fn render_setup_page(
|
||||
}
|
||||
|
||||
fn apply_form_to_current_config(form: &SaveForm) -> Result<()> {
|
||||
let interface_options = interfaces::get_interface_options();
|
||||
apply_form_to_current_config_with_options(form, &interface_options)
|
||||
}
|
||||
|
||||
fn apply_form_to_current_config_with_options(
|
||||
form: &SaveForm,
|
||||
interface_options: &[interfaces::InterfaceOption],
|
||||
) -> Result<()> {
|
||||
let downlink = form
|
||||
.downlink_mbps
|
||||
.trim()
|
||||
@@ -460,48 +461,51 @@ fn apply_form_to_current_config(form: &SaveForm) -> Result<()> {
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let mut config = CURRENT_CONFIG.lock();
|
||||
config.node_name = form.node_name.trim().to_string();
|
||||
config.mbps_to_internet = downlink;
|
||||
config.mbps_to_network = uplink;
|
||||
config.allow_subnets = allow_subnets;
|
||||
let allow_legacy_xdp = config.bridge_mode == BridgeMode::XDP || existing_config_uses_xdp();
|
||||
match resolve_requested_bridge_mode(&form.bridge_mode, allow_legacy_xdp)? {
|
||||
"single" => {
|
||||
config.bridge_mode = BridgeMode::Single;
|
||||
let allow_legacy_xdp =
|
||||
CURRENT_CONFIG.lock().bridge_mode == BridgeMode::XDP || existing_config_uses_direct_xdp();
|
||||
let requested_mode = resolve_requested_bridge_mode(&form.bridge_mode, allow_legacy_xdp)?;
|
||||
let (to_internet, to_network, internet_vlan, network_vlan) = match requested_mode {
|
||||
BridgeMode::Single => {
|
||||
let interface = form
|
||||
.single_interface
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("A shaping interface is required."))?;
|
||||
config.to_internet = interface.to_string();
|
||||
config.to_network.clear();
|
||||
config.internet_vlan = parse_vlan(form.internet_vlan.as_deref())?;
|
||||
config.network_vlan = parse_vlan(form.network_vlan.as_deref())?;
|
||||
(
|
||||
interface.to_string(),
|
||||
String::new(),
|
||||
parse_vlan(form.internet_vlan.as_deref())?,
|
||||
parse_vlan(form.network_vlan.as_deref())?,
|
||||
)
|
||||
}
|
||||
"linux" | "xdp" => {
|
||||
BridgeMode::Linux | BridgeMode::XDP | BridgeMode::CompatibilityShim => {
|
||||
let to_internet = form.to_internet.trim();
|
||||
let to_network = form
|
||||
.to_network
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("A network-facing interface is required."))?;
|
||||
if form.to_internet.trim() == to_network {
|
||||
bail!("Internet and network interfaces must be different.");
|
||||
}
|
||||
config.bridge_mode = if form.bridge_mode == "xdp" {
|
||||
BridgeMode::XDP
|
||||
} else {
|
||||
BridgeMode::Linux
|
||||
};
|
||||
config.to_internet = form.to_internet.trim().to_string();
|
||||
config.to_network = to_network.to_string();
|
||||
config.internet_vlan = 0;
|
||||
config.network_vlan = 0;
|
||||
(to_internet.to_string(), to_network.to_string(), 0, 0)
|
||||
}
|
||||
_ => bail!("Unsupported bridge mode."),
|
||||
}
|
||||
};
|
||||
interfaces::validate_mode_interfaces_with_options(
|
||||
interface_options,
|
||||
requested_mode,
|
||||
&to_internet,
|
||||
&to_network,
|
||||
)?;
|
||||
let mut config = CURRENT_CONFIG.lock();
|
||||
config.node_name = form.node_name.trim().to_string();
|
||||
config.mbps_to_internet = downlink;
|
||||
config.mbps_to_network = uplink;
|
||||
config.allow_subnets = allow_subnets;
|
||||
config.bridge_mode = requested_mode;
|
||||
config.to_internet = to_internet;
|
||||
config.to_network = to_network;
|
||||
config.internet_vlan = internet_vlan;
|
||||
config.network_vlan = network_vlan;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -532,10 +536,15 @@ fn ssl_request_value(
|
||||
.then(|| external_hostname.unwrap_or("").trim().to_string())
|
||||
}
|
||||
|
||||
fn resolve_requested_bridge_mode(requested_mode: &str, allow_legacy_xdp: bool) -> Result<&str> {
|
||||
fn resolve_requested_bridge_mode(
|
||||
requested_mode: &str,
|
||||
allow_legacy_xdp: bool,
|
||||
) -> Result<BridgeMode> {
|
||||
match requested_mode {
|
||||
"single" | "linux" => Ok(requested_mode),
|
||||
"xdp" if allow_legacy_xdp => Ok("xdp"),
|
||||
"single" => Ok(BridgeMode::Single),
|
||||
"linux" => Ok(BridgeMode::Linux),
|
||||
"shim" => Ok(BridgeMode::CompatibilityShim),
|
||||
"xdp" if allow_legacy_xdp => Ok(BridgeMode::XDP),
|
||||
_ => bail!("Unsupported bridge mode."),
|
||||
}
|
||||
}
|
||||
@@ -767,17 +776,20 @@ fn base_css() -> &'static str {
|
||||
|
||||
fn base_js() -> &'static str {
|
||||
"function bridgeSectionForMode(mode){return mode==='single'?'single':'bridge';}\
|
||||
function syncModeSections(){const mode=document.querySelector('select[name=\"bridge_mode\"]')?.value||'linux';const visibleSection=bridgeSectionForMode(mode);document.querySelectorAll('.mode-section').forEach((section)=>{section.hidden=section.dataset.modeSection!==visibleSection;});updateCoreSetupValidation();}\
|
||||
function eligibilityAttributeForMode(mode){if(mode==='single')return 'data-single-eligible';if(mode==='shim')return 'data-shim-eligible';return 'data-bridge-eligible';}\
|
||||
function updateInterfaceEligibility(){const mode=currentBridgeMode();const attribute=eligibilityAttributeForMode(mode);document.querySelectorAll('[data-interface-option]').forEach((option)=>{option.disabled=option.getAttribute(attribute)!=='true';});document.querySelectorAll('select[name=\"to_internet\"],select[name=\"to_network\"],select[name=\"single_interface\"]').forEach((select)=>{if(select.selectedOptions[0]?.disabled){select.value='';}});}\
|
||||
function syncModeSections(){const modeSelect=document.querySelector('select[name=\"bridge_mode\"]');const mode=modeSelect?.value||'linux';const visibleSection=bridgeSectionForMode(mode);document.querySelectorAll('.mode-section').forEach((section)=>{section.hidden=section.dataset.modeSection!==visibleSection;});if(document.activeElement?.closest('.mode-section[hidden]')){modeSelect?.focus();}updateInterfaceEligibility();updateCoreSetupValidation();}\
|
||||
function showBusy(message){const overlay=document.getElementById('busyOverlay');const target=document.getElementById('busyMessage');if(target&&message){target.textContent=message;}if(overlay){overlay.hidden=false;}}\
|
||||
function hideBusy(){const overlay=document.getElementById('busyOverlay');if(overlay){overlay.hidden=true;}}\
|
||||
function setSubmitDisabled(form,disabled){const button=form?.querySelector('button[type=\"submit\"]');if(button){button.disabled=!!disabled;}}\
|
||||
function submitWithProgress(form,message){setSubmitDisabled(form,true);showBusy(message);return true;}\
|
||||
function currentBridgeMode(){return document.querySelector('select[name=\"bridge_mode\"]')?.value||'linux';}\
|
||||
function linuxInterfacesAreDistinct(){const toInternet=document.querySelector('select[name=\"to_internet\"]')?.value||'';const toNetwork=document.querySelector('select[name=\"to_network\"]')?.value||'';return !toInternet||!toNetwork||toInternet!==toNetwork;}\
|
||||
function updateCoreSetupValidation(){const form=document.getElementById('coreSetupForm');if(!form){return true;}const message=document.getElementById('interfaceValidationMessage');const invalid=currentBridgeMode()!=='single'&&!linuxInterfacesAreDistinct();if(message){message.hidden=!invalid;}setSubmitDisabled(form,invalid);return !invalid;}\
|
||||
function validateAndSubmitSetup(form){if(!updateCoreSetupValidation()){hideBusy();setSubmitDisabled(form,false);return false;}return submitWithProgress(form,'Saving setup and applying managed network changes...');}\
|
||||
function selectedInterfaceIsEligible(select){return Boolean(select?.value&&select.selectedOptions[0]&&!select.selectedOptions[0].disabled);}\
|
||||
function selectedInterfacesAreValid(){if(currentBridgeMode()==='single'){return selectedInterfaceIsEligible(document.querySelector('select[name=\"single_interface\"]'));}const toInternet=document.querySelector('select[name=\"to_internet\"]');const toNetwork=document.querySelector('select[name=\"to_network\"]');return selectedInterfaceIsEligible(toInternet)&&selectedInterfaceIsEligible(toNetwork)&&toInternet.value!==toNetwork.value;}\
|
||||
function updateCoreSetupValidation(){const form=document.getElementById('coreSetupForm');if(!form){return true;}const message=document.getElementById('interfaceValidationMessage');const invalid=!selectedInterfacesAreValid();if(message){message.textContent=currentBridgeMode()==='single'?'Select an eligible shaping interface.':'Select two different eligible interfaces.';message.hidden=!invalid;}setSubmitDisabled(form,invalid);return !invalid;}\
|
||||
function validateAndSubmitSetup(form){if(!updateCoreSetupValidation()){hideBusy();setSubmitDisabled(form,false);return false;}return submitWithProgress(form,'Saving setup...');}\
|
||||
function resetBusyState(){hideBusy();document.querySelectorAll('form').forEach((form)=>setSubmitDisabled(form,false));updateCoreSetupValidation();}\
|
||||
function setupPage(){const mode=document.querySelector('select[name=\"bridge_mode\"]');if(mode){mode.addEventListener('change',syncModeSections);}const toInternet=document.querySelector('select[name=\"to_internet\"]');if(toInternet){toInternet.addEventListener('change',updateCoreSetupValidation);}const toNetwork=document.querySelector('select[name=\"to_network\"]');if(toNetwork){toNetwork.addEventListener('change',updateCoreSetupValidation);}window.addEventListener('pageshow',resetBusyState);syncModeSections();resetBusyState();}"
|
||||
function setupPage(){const mode=document.querySelector('select[name=\"bridge_mode\"]');if(mode){mode.addEventListener('change',syncModeSections);}document.querySelectorAll('select[name=\"to_internet\"],select[name=\"to_network\"],select[name=\"single_interface\"]').forEach((select)=>select.addEventListener('change',updateCoreSetupValidation));window.addEventListener('pageshow',resetBusyState);syncModeSections();resetBusyState();}"
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
@@ -805,14 +817,63 @@ fn selected(value: bool) -> &'static str {
|
||||
if value { " selected" } else { "" }
|
||||
}
|
||||
|
||||
fn interface_option_html(iface: &interfaces::InterfaceOption, selected_value: &str) -> String {
|
||||
format!(
|
||||
"<option value=\"{}\" data-interface-option data-bridge-eligible=\"{}\" data-shim-eligible=\"{}\" data-single-eligible=\"{}\"{}>{}</option>",
|
||||
escape_html(&iface.name),
|
||||
iface.bridge_eligible,
|
||||
iface.compatibility_shim_eligible,
|
||||
iface.single_interface_eligible,
|
||||
selected(selected_value == iface.name),
|
||||
escape_html(&iface.label)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{render_pending_page, resolve_requested_bridge_mode, runtime_access_url};
|
||||
use super::{
|
||||
SaveForm, apply_form_to_current_config_with_options, interface_option_html,
|
||||
render_pending_page, resolve_requested_bridge_mode, runtime_access_url,
|
||||
};
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG};
|
||||
use crate::interfaces::InterfaceOption;
|
||||
use crate::test_support::lock_current_config;
|
||||
use lqos_config::{Config, SslConfig};
|
||||
|
||||
fn shim_save_form() -> SaveForm {
|
||||
SaveForm {
|
||||
token: "test-token".to_string(),
|
||||
node_name: "Shim Node".to_string(),
|
||||
downlink_mbps: "10000".to_string(),
|
||||
uplink_mbps: "5000".to_string(),
|
||||
bridge_mode: "shim".to_string(),
|
||||
to_internet: "bond0".to_string(),
|
||||
to_network: Some("bond1".to_string()),
|
||||
single_interface: None,
|
||||
internet_vlan: None,
|
||||
network_vlan: None,
|
||||
allow_subnets: "10.0.0.0/8".to_string(),
|
||||
enable_ssl: None,
|
||||
external_hostname: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn shim_interface_option(name: &str, eligible: bool) -> InterfaceOption {
|
||||
InterfaceOption {
|
||||
name: name.to_string(),
|
||||
label: name.to_string(),
|
||||
bridge_eligible: false,
|
||||
compatibility_shim_eligible: eligible,
|
||||
single_interface_eligible: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdp_mode_is_preserved_for_existing_xdp_config() {
|
||||
assert_eq!(resolve_requested_bridge_mode("xdp", true).unwrap(), "xdp");
|
||||
assert_eq!(
|
||||
resolve_requested_bridge_mode("xdp", true).unwrap(),
|
||||
BridgeMode::XDP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -821,6 +882,81 @@ mod tests {
|
||||
assert!(error.to_string().contains("Unsupported bridge mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_is_available_for_new_installs() {
|
||||
assert_eq!(
|
||||
resolve_requested_bridge_mode("shim", false).unwrap(),
|
||||
BridgeMode::CompatibilityShim
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_form_updates_first_run_config() {
|
||||
let _test_guard = lock_current_config();
|
||||
let previous_builder = CURRENT_CONFIG.lock().clone();
|
||||
let form = shim_save_form();
|
||||
let options = ["bond0", "bond1"].map(|name| shim_interface_option(name, true));
|
||||
|
||||
apply_form_to_current_config_with_options(&form, &options).expect("apply shim form");
|
||||
let candidate = crate::setup_actions::build_candidate_config(None);
|
||||
|
||||
assert_eq!(
|
||||
CURRENT_CONFIG.lock().bridge_mode,
|
||||
BridgeMode::CompatibilityShim
|
||||
);
|
||||
assert!(candidate.bridge.as_ref().is_some_and(|bridge| {
|
||||
bridge.use_xdp_bridge
|
||||
&& bridge.compatibility_shim_enabled()
|
||||
&& bridge.to_internet == "bond0"
|
||||
&& bridge.to_network == "bond1"
|
||||
}));
|
||||
|
||||
*CURRENT_CONFIG.lock() = previous_builder;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_shim_form_rejects_ineligible_interface_without_mutating_config() {
|
||||
let _test_guard = lock_current_config();
|
||||
let previous_builder = CURRENT_CONFIG.lock().clone();
|
||||
let form = shim_save_form();
|
||||
let options = [
|
||||
shim_interface_option("bond0", true),
|
||||
shim_interface_option("bond1", false),
|
||||
];
|
||||
|
||||
let error = apply_form_to_current_config_with_options(&form, &options)
|
||||
.expect_err("reject ineligible shim interface");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Network-facing interface bond1 is not eligible")
|
||||
);
|
||||
let current = CURRENT_CONFIG.lock().clone();
|
||||
*CURRENT_CONFIG.lock() = previous_builder.clone();
|
||||
assert_eq!(current.bridge_mode, previous_builder.bridge_mode);
|
||||
assert_eq!(current.to_internet, previous_builder.to_internet);
|
||||
assert_eq!(current.to_network, previous_builder.to_network);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_interface_option_carries_mode_eligibility() {
|
||||
let option = InterfaceOption {
|
||||
name: "bond0".to_string(),
|
||||
label: "bond0 - virtual".to_string(),
|
||||
bridge_eligible: false,
|
||||
compatibility_shim_eligible: true,
|
||||
single_interface_eligible: false,
|
||||
};
|
||||
|
||||
let html = interface_option_html(&option, "bond0");
|
||||
|
||||
assert!(html.contains("data-bridge-eligible=\"false\""));
|
||||
assert!(html.contains("data-shim-eligible=\"true\""));
|
||||
assert!(html.contains("data-single-eligible=\"false\""));
|
||||
assert!(html.contains(" selected>bond0 - virtual</option>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_page_preserves_ssl_request_fields() {
|
||||
let html = render_pending_page(
|
||||
|
||||
Reference in new issue
Block a user