From cf3c251200fa5c21c730cec2c23ec04c8a719f6d Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 03:40:40 +0000 Subject: [PATCH 1/7] changes --- docs-es/v2.0/Quickstart/quickstart-prereq.md | 20 +- docs-es/v2.0/bridge-es.md | 30 +- docs-es/v2.0/insight-es.md | 12 +- docs-es/v2.0/recipes-proxmox-vm-es.md | 8 +- docs-es/v2.0/troubleshooting-es.md | 6 +- docs-es/v2.0/update-es.md | 2 +- docs/v2.0/bridge.md | 28 +- docs/v2.0/insight.md | 12 +- docs/v2.0/recipes-proxmox-vm.md | 8 +- docs/v2.0/troubleshooting.md | 6 +- docs/v2.0/update.md | 2 +- src/build_dpkg.sh | 10 +- src/build_rust.sh | 11 + src/rust/Cargo.lock | 39 + src/rust/Cargo.toml | 1 + src/rust/lqos_bus/src/bus/request.rs | 4 +- src/rust/lqos_bus/src/bus/response.rs | 41 +- src/rust/lqos_bus/src/lib.rs | 9 +- src/rust/lqos_netplan_helper/Cargo.toml | 19 + src/rust/lqos_netplan_helper/src/inspect.rs | 1361 +++++++++++++++++ src/rust/lqos_netplan_helper/src/lib.rs | 12 + src/rust/lqos_netplan_helper/src/main.rs | 109 ++ src/rust/lqos_netplan_helper/src/protocol.rs | 70 + .../lqos_netplan_helper/src/transaction.rs | 1294 ++++++++++++++++ src/rust/lqos_python/src/device_weights.rs | 8 +- src/rust/lqos_python/src/lib.rs | 6 +- src/rust/lqos_setup/Cargo.toml | 3 +- src/rust/lqos_setup/src/bridge_mode.rs | 4 +- src/rust/lqos_setup/src/interfaces.rs | 1 + src/rust/lqos_setup/src/main.rs | 358 +++-- src/rust/lqosd/Cargo.toml | 1 + src/rust/lqosd/src/lts2_sys/capabilities.rs | 227 +++ .../lqosd/src/lts2_sys/control_channel.rs | 35 +- src/rust/lqosd/src/lts2_sys/license_grant.rs | 30 +- .../src/lts2_sys/lts2_client/ingestor.rs | 3 + .../lts2_client/ingestor/message_queue.rs | 6 +- .../lqosd/src/lts2_sys/lts2_client/mod.rs | 3 + src/rust/lqosd/src/lts2_sys/mod.rs | 4 + src/rust/lqosd/src/lts2_sys/shared_types.rs | 25 +- src/rust/lqosd/src/main.rs | 382 +++-- .../src/node_manager/js_build/src/chatbot.js | 27 +- .../js_build/src/components/timescale.js | 5 +- .../js_build/src/config/config_helper.js | 6 +- .../js_build/src/config_interface.js | 904 ++++++++++- .../node_manager/js_build/src/config_lts.js | 242 ++- .../node_manager/js_build/src/cpu_weights.js | 2 +- .../src/dashlets/executive_snapshot.js | 2 +- .../src/dashlets/queue_stats_total.js | 2 +- .../src/dashlets/throughput_bps_dash.js | 2 +- .../src/dashlets/total_retransmits.js | 2 +- .../src/node_manager/js_build/src/help.js | 19 +- src/rust/lqosd/src/node_manager/local_api.rs | 14 +- .../src/node_manager/local_api/config.rs | 9 + .../lqosd/src/node_manager/local_api/lts.rs | 52 +- .../node_manager/local_api/network_mode.rs | 319 ++++ .../lqosd/src/node_manager/static2/api.html | 12 +- .../src/node_manager/static2/chatbot.html | 4 +- .../static2/config_interface.html | 234 ++- .../src/node_manager/static2/config_lts.html | 95 +- .../lqosd/src/node_manager/static2/help.html | 8 +- .../src/node_manager/static2/lts_trial.html | 1 - .../src/node_manager/static2/node_manager.css | 124 ++ src/rust/lqosd/src/node_manager/template.rs | 39 +- src/rust/lqosd/src/node_manager/ws.rs | 150 +- .../lqosd/src/node_manager/ws/messages.rs | 9 +- .../node_manager/ws/single_user_channels.rs | 16 + src/rust/lqosd/src/remote_commands.rs | 4 + src/rust/lqosd/src/shaping_runtime.rs | 105 ++ src/rust/lqosd/src/throughput_tracker/mod.rs | 6 +- .../throughput_tracker/stats_submission.rs | 11 +- 70 files changed, 5999 insertions(+), 636 deletions(-) create mode 100644 src/rust/lqos_netplan_helper/Cargo.toml create mode 100644 src/rust/lqos_netplan_helper/src/inspect.rs create mode 100644 src/rust/lqos_netplan_helper/src/lib.rs create mode 100644 src/rust/lqos_netplan_helper/src/main.rs create mode 100644 src/rust/lqos_netplan_helper/src/protocol.rs create mode 100644 src/rust/lqos_netplan_helper/src/transaction.rs create mode 100644 src/rust/lqosd/src/lts2_sys/capabilities.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/network_mode.rs create mode 100644 src/rust/lqosd/src/shaping_runtime.rs diff --git a/docs-es/v2.0/Quickstart/quickstart-prereq.md b/docs-es/v2.0/Quickstart/quickstart-prereq.md index 7b4c12b1..de1e8705 100644 --- a/docs-es/v2.0/Quickstart/quickstart-prereq.md +++ b/docs-es/v2.0/Quickstart/quickstart-prereq.md @@ -48,11 +48,11 @@ Assuming your interfaces are ens19 and ens20, here is what your file would look network: ethernets: ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false bridges: br0: interfaces: @@ -64,7 +64,7 @@ network: Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section. ``` -By setting `dhcp4: no` and `dhcp6: no`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. +By setting `dhcp4: false` and `dhcp6: false`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. Then run @@ -88,18 +88,18 @@ Assuming your interfaces are ens19 and ens20, here is what your file would look network: ethernets: ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false version: 2 ``` ```{note} Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section. ``` -By setting `dhcp4: no` and `dhcp6: no`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. +By setting `dhcp4: false` and `dhcp6: false`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. Then run diff --git a/docs-es/v2.0/bridge-es.md b/docs-es/v2.0/bridge-es.md index 67142374..20a00eed 100644 --- a/docs-es/v2.0/bridge-es.md +++ b/docs-es/v2.0/bridge-es.md @@ -11,6 +11,14 @@ El puente regular de Linux es recomendado para la mayoría de las instalaciones. A continuación, se encuentran las instrucciones para configurar Netplan, ya sea usando el puente de Linux o el puente Bifrost con XDP: +```{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} +Si un cambio temporizado de Netplan interrumpe brevemente la sesión del navegador, vuelva a la página Network Mode cuando regrese la conectividad. LibreQoS retomará automáticamente desde esa página el flujo pendiente de confirmar o revertir. +``` + ## Opción A: Configuración de Netplan para un puente regular de Linux (Recomendado) Ubuntu Server utiliza Netplan, el cual se basa en archivos .yaml ubicados en /etc/netplan para determinar la configuración de interfaces. @@ -26,11 +34,11 @@ Asumiendo que sus interfaces de regulación son ens19 y ens20, su archivo se ver network: ethernets: ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false bridges: br0: interfaces: @@ -42,7 +50,7 @@ network: Asegurese de reemplazar `ens19` y `ens20` en el ejemplo anterior con las interfaces reales que utilizará para regular el tráfico. El orden de las interfaces no importa en esta sección. ``` -Al establecer `dhcp4: no` y `dhcp6: no`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas. +Al establecer `dhcp4: false` y `dhcp6: false`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas. Después ejecute: @@ -66,18 +74,18 @@ Asumiendo que sus interfaces de regulación son ens19 y ens20, su archivo se ver network: ethernets: ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false version: 2 ``` ```{note} Asegurese de reemplazar `ens19` y `ens20` en el ejemplo anterior con las interfaces reales que utilizará para regular el tráfico. El orden de las interfaces no importa en esta sección. ``` -Al establecer `dhcp4: no` y `dhcp6: no`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas. +Al establecer `dhcp4: false` y `dhcp6: false`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas. Después ejecute: @@ -86,4 +94,4 @@ sudo chmod 600 /etc/netplan/libreqos.yaml sudo netplan apply ``` -Para usar el puente XDP, asegurese de establecer `use_xdp_bridge` como `true` en el archivo lqos.conf dentro de la sección [Configuración](configuration-es.md). \ No newline at end of file +Para usar el puente XDP, asegurese de establecer `use_xdp_bridge` como `true` en el archivo lqos.conf dentro de la sección [Configuración](configuration-es.md). diff --git a/docs-es/v2.0/insight-es.md b/docs-es/v2.0/insight-es.md index 10822dee..c9cb4bb5 100644 --- a/docs-es/v2.0/insight-es.md +++ b/docs-es/v2.0/insight-es.md @@ -124,13 +124,13 @@ Notas operativas: ### Límites de circuitos mapeados y estado de licencia -`ShapedDevices.csv` puede contener entradas ilimitadas. En compilaciones v2.0 actuales, la admisión al estado de shaping activo depende del estado válido de la licencia Insight. +`ShapedDevices.csv` puede contener entradas ilimitadas. En compilaciones v2.0 actuales, la admisión al estado de shaping activo depende de un estado válido de licencia/grant con derecho. -Sin una suscripción/licencia Insight válida, LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Los circuitos mapeados válidos adicionales permanecen fuera del shaping activo hasta que se restaure una licencia Insight válida. +Sin un estado válido de licencia/grant de Insight o Local, LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Los circuitos mapeados válidos adicionales permanecen fuera del shaping activo hasta que se restaure una licencia válida con derecho. -Una suscripción/licencia Insight válida habilita conteos de circuitos mapeados por encima del límite predeterminado de 1000. +Un estado válido de licencia/grant con derecho habilita conteos de circuitos mapeados por encima del límite predeterminado de 1000. -El comportamiento predeterminado del límite de 1000 aplica cuando Insight está: +El comportamiento predeterminado del límite de 1000 aplica cuando el estado de licencia/grant está: - ausente - expirado - inválido por cualquier motivo @@ -145,6 +145,6 @@ Cuando se alcanza el límite, los operadores normalmente verán: Cuando ocurra: -1. Revise estado de licencia Insight en la UI. +1. Revise el estado de licencia en la página `License & Services`. 2. Revise `journalctl -u lqosd` para conteos requested/allowed/dropped. -3. Verifique si el nodo está operando con el límite predeterminado de 1000 circuitos mapeados porque el estado actual de Insight/licencia es inválido. +3. Verifique si el nodo está operando con el límite predeterminado de 1000 circuitos mapeados porque el estado actual de licencia/grant es inválido. diff --git a/docs-es/v2.0/recipes-proxmox-vm-es.md b/docs-es/v2.0/recipes-proxmox-vm-es.md index 2eb6b0f3..e9ad24bd 100644 --- a/docs-es/v2.0/recipes-proxmox-vm-es.md +++ b/docs-es/v2.0/recipes-proxmox-vm-es.md @@ -124,11 +124,11 @@ network: nameservers: addresses: [1.1.1.1, 8.8.8.8] ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ``` Luego configure el comportamiento bridge segun [Configure Shaping Bridge](bridge-es.md). diff --git a/docs-es/v2.0/troubleshooting-es.md b/docs-es/v2.0/troubleshooting-es.md index f9970518..29dc1f17 100644 --- a/docs-es/v2.0/troubleshooting-es.md +++ b/docs-es/v2.0/troubleshooting-es.md @@ -197,9 +197,9 @@ Si ve mensajes como: LibreQoS está aplicando un límite de circuitos mapeados. -`ShapedDevices.csv` puede contener entradas ilimitadas, pero sin una suscripción/licencia Insight válida LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo. +`ShapedDevices.csv` puede contener entradas ilimitadas, pero sin un estado válido de licencia/grant de Insight o Local LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo. -El límite predeterminado de 1000 circuitos mapeados aplica cuando Insight está: +El límite predeterminado de 1000 circuitos mapeados aplica cuando el estado de licencia/grant está: - ausente - expirado - inválido por cualquier motivo @@ -213,7 +213,7 @@ Síntomas típicos visibles para el operador: Checks recomendados: -1. Confirmar estado de licencia Insight en UI. +1. Confirmar el estado de licencia en la página `License & Services`. 2. Revisar logs de `lqosd` para requested/allowed/dropped. 3. Reducir circuitos mapeados (corto plazo) o ajustar licencia/límites (largo plazo). diff --git a/docs-es/v2.0/update-es.md b/docs-es/v2.0/update-es.md index fb39c2ea..27ecd584 100644 --- a/docs-es/v2.0/update-es.md +++ b/docs-es/v2.0/update-es.md @@ -7,7 +7,7 @@ Si usas el puente XDP, el tráfico dejará de pasar por el puente mientras lqosd ## Si instalaste el .deb ```{important} -Desde v2.0, la ingesta de circuitos mapeados depende del estado válido de la licencia Insight. Sin una suscripción/licencia Insight válida, LibreQoS solo lee los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Esto incluye estado local de grant/licencia expirado o inválido por cualquier motivo. Vea [Comportamiento de licenciamiento de Insight](insight-es.md#límites-de-circuitos-mapeados-y-estado-de-licencia) y [Solución de problemas](troubleshooting-es.md). +Desde v2.0, la ingesta de circuitos mapeados depende de un estado válido de licencia/grant con derecho. Sin un estado válido de licencia/grant de Insight o Local, LibreQoS solo lee los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Esto incluye estado local de grant/licencia expirado o inválido por cualquier motivo. Vea [Comportamiento de licenciamiento de Insight](insight-es.md#límites-de-circuitos-mapeados-y-estado-de-licencia) y [Solución de problemas](troubleshooting-es.md). ``` Ejecuta: diff --git a/docs/v2.0/bridge.md b/docs/v2.0/bridge.md index 576670b7..1127a711 100644 --- a/docs/v2.0/bridge.md +++ b/docs/v2.0/bridge.md @@ -11,6 +11,14 @@ The regular Linux bridge is recommended for most installations. The Linux Bridge Below are the instructions to configure Netplan, whether using the Linux Bridge or Bifrost XDP bridge: +```{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} +If a timed Netplan change briefly interrupts your browser session, return to the Network Mode page after connectivity comes back. LibreQoS will resume the pending confirm or revert flow from that page automatically. +``` + ## Option A: Netplan config for a regular Linux bridge (Recommended) Ubuntu Server uses NetPlan, which uses .yaml files in /etc/netplan to determine interface settings. @@ -26,11 +34,11 @@ Assuming your shaping interfaces are ens19 and ens20, here is what your file wou network: ethernets: ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false bridges: br0: interfaces: @@ -42,7 +50,7 @@ network: Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section. ``` -By setting `dhcp4: no` and `dhcp6: no`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. +By setting `dhcp4: false` and `dhcp6: false`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. Then run @@ -66,18 +74,18 @@ Assuming your shaping interfaces are ens19 and ens20, here is what your file wou network: ethernets: ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false version: 2 ``` ```{note} Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section. ``` -By setting `dhcp4: no` and `dhcp6: no`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. +By setting `dhcp4: false` and `dhcp6: false`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned. Then run diff --git a/docs/v2.0/insight.md b/docs/v2.0/insight.md index 840c4010..82cb7170 100644 --- a/docs/v2.0/insight.md +++ b/docs/v2.0/insight.md @@ -119,13 +119,13 @@ Operational notes: ### Mapped circuit limits and license state -`ShapedDevices.csv` can contain unlimited entries. In current v2.0 builds, admission into active shaping state depends on valid Insight license state. +`ShapedDevices.csv` can contain unlimited entries. In current v2.0 builds, admission into active shaping state depends on valid entitled license/grant state. -Without a valid Insight subscription/license, LibreQoS admits only the first 1000 valid mapped circuits into active shaping state. Additional valid mapped circuits remain outside active shaping until valid Insight licensing is restored. +Without a valid Insight or Local license/grant state, LibreQoS admits only the first 1000 valid mapped circuits into active shaping state. Additional valid mapped circuits remain outside active shaping until valid entitled licensing is restored. -A valid Insight subscription/license enables mapped-circuit counts above the default 1000 limit. +A valid entitled license/grant state enables mapped-circuit counts above the default 1000 limit. -The default 1000-limit behavior applies when Insight is: +The default 1000-limit behavior applies when license/grant state is: - missing - expired - otherwise invalid @@ -139,6 +139,6 @@ When the limit is reached, operators will typically see: - `Bakery mapped circuit cap enforced` When this occurs, check: -1. Insight license status in the UI. +1. License status in the `License & Services` page. 2. `journalctl -u lqosd` for requested/allowed/dropped mapped counts. -3. Whether your node is operating at the default 1000 mapped-circuit limit because the current Insight/license state is invalid. +3. Whether your node is operating at the default 1000 mapped-circuit limit because the current license/grant state is invalid. diff --git a/docs/v2.0/recipes-proxmox-vm.md b/docs/v2.0/recipes-proxmox-vm.md index b86545e9..d012bca6 100644 --- a/docs/v2.0/recipes-proxmox-vm.md +++ b/docs/v2.0/recipes-proxmox-vm.md @@ -138,11 +138,11 @@ network: nameservers: addresses: [1.1.1.1, 8.8.8.8] ens19: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ens20: - dhcp4: no - dhcp6: no + dhcp4: false + dhcp6: false ``` Then configure bridge behavior per [Configure Shaping Bridge](bridge.md). diff --git a/docs/v2.0/troubleshooting.md b/docs/v2.0/troubleshooting.md index cf9ba0a9..2ea8075e 100644 --- a/docs/v2.0/troubleshooting.md +++ b/docs/v2.0/troubleshooting.md @@ -276,9 +276,9 @@ If logs mention messages like: - `Mapped circuit limit reached` - `Bakery mapped circuit cap enforced` -`ShapedDevices.csv` can contain unlimited entries, but without a valid Insight subscription/license LibreQoS admits only the first 1000 valid mapped circuits into active shaping state. +`ShapedDevices.csv` can contain unlimited entries, but without a valid Insight or Local license/grant state LibreQoS admits only the first 1000 valid mapped circuits into active shaping state. -The default 1000 mapped-circuit limit applies when Insight is: +The default 1000 mapped-circuit limit applies when license/grant state is: - missing - expired - otherwise invalid @@ -291,7 +291,7 @@ Typical operator-visible symptoms: - partial shaping, with circuits beyond the active limit left outside shaping state Recommended checks: -1. Confirm Insight/license status in the UI. +1. Confirm license status in the `License & Services` page. 2. Review `lqosd` logs for requested/allowed/dropped counts. 3. Reduce mapped circuit count (short term) or update licensing/limits (long term). diff --git a/docs/v2.0/update.md b/docs/v2.0/update.md index 0e09645e..fa6ba107 100644 --- a/docs/v2.0/update.md +++ b/docs/v2.0/update.md @@ -7,7 +7,7 @@ If you use the XDP bridge, traffic will briefly stop passing through the bridge ## If you installed the .deb ```{important} -Starting in v2.0, mapped-circuit ingest depends on valid Insight license state. Without a valid Insight subscription/license, LibreQoS reads only the first 1000 valid mapped circuits into active shaping state. This includes expired or otherwise invalid local grant/license state. See [Insight Licensing Behavior](insight.md#mapped-circuit-limits-and-license-state) and [Troubleshooting](troubleshooting.md). +Starting in v2.0, mapped-circuit ingest depends on valid entitled license/grant state. Without a valid Insight or Local license/grant state, LibreQoS reads only the first 1000 valid mapped circuits into active shaping state. This includes expired or otherwise invalid local grant/license state. See [Insight Licensing Behavior](insight.md#mapped-circuit-limits-and-license-state) and [Troubleshooting](troubleshooting.md). ``` Run: diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index 19346921..c7f6a8ce 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -60,6 +60,7 @@ LQOS_BIN_FILES=( RUSTPROGS=( lqosd + lqos_netplan_helper lqtop xdp_iphash_to_cpu_cmdline xdp_pping @@ -107,6 +108,7 @@ pushd rust > /dev/null || exit # Build only required binaries and artifacts (exclude lqos_support_tool executable) cargo build --release \ -p lqosd \ + -p lqos_netplan_helper \ -p lqtop \ -p xdp_iphash_to_cpu_cmdline \ -p xdp_pping \ @@ -160,9 +162,12 @@ sudo chown -R root:root /opt/libreqos install -m 0644 /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.service install -m 0644 /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service install -m 0644 /opt/libreqos/src/bin/lqos_api.service.example /etc/systemd/system/lqos_api.service +/bin/rm -f /etc/systemd/system/lqos_netplan_helper.service || true /bin/systemctl daemon-reload || true /bin/systemctl stop lqos_node_manager || true # In case it's running from a previous release /bin/systemctl disable lqos_node_manager || true # In case it's running from a previous release +/bin/systemctl stop lqos_netplan_helper || true +/bin/systemctl disable lqos_netplan_helper || true /bin/systemctl enable lqosd lqos_scheduler lqos_api || true /bin/systemctl restart lqosd lqos_scheduler lqos_api || true popd > /dev/null @@ -172,8 +177,9 @@ EOF cat < postrm #!/bin/bash set +e -/bin/systemctl stop lqosd lqos_scheduler lqos_api || true -/bin/systemctl disable lqosd lqos_scheduler lqos_api || true +/bin/systemctl stop lqos_netplan_helper lqosd lqos_scheduler lqos_api || true +/bin/systemctl disable lqos_netplan_helper lqosd lqos_scheduler lqos_api || true +/bin/rm -f /etc/systemd/system/lqos_netplan_helper.service || true /bin/systemctl daemon-reload || true exit 0 EOF diff --git a/src/build_rust.sh b/src/build_rust.sh index 157ca2fb..6331e104 100755 --- a/src/build_rust.sh +++ b/src/build_rust.sh @@ -61,6 +61,7 @@ rustup update echo "Please wait while the system is compiled. Service will not be interrupted during this stage." PROGS=( lqosd + lqos_netplan_helper lqtop xdp_iphash_to_cpu_cmdline xdp_pping @@ -73,6 +74,7 @@ PROGS=( ) BUILD_PACKAGES=( lqosd + lqos_netplan_helper lqtop xdp_iphash_to_cpu_cmdline xdp_pping @@ -193,6 +195,15 @@ if service_exists lqosd; then echo "lqosd is running as a service. Restarting it. You may need to enter your sudo password." sudo systemctl restart lqosd fi +if service_exists lqos_netplan_helper; then + echo "lqos_netplan_helper is no longer run as a service. Stopping and disabling it." + sudo systemctl stop lqos_netplan_helper || true + sudo systemctl disable lqos_netplan_helper || true + if [ -f /etc/systemd/system/lqos_netplan_helper.service ]; then + sudo rm -f /etc/systemd/system/lqos_netplan_helper.service + sudo systemctl daemon-reload + fi +fi if service_exists lqos_scheduler; then echo "lqos_scheduler is running as a service. Restarting it. You may need to enter your sudo password." sudo systemctl restart lqos_scheduler diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index e735c9d1..d8705f06 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2099,6 +2099,24 @@ dependencies = [ "lqos_sys", ] +[[package]] +name = "lqos_netplan_helper" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "default-net", + "libc", + "lqos_config", + "lqos_utils", + "serde", + "serde_json", + "serde_yaml", + "toml", + "tracing", + "uuid", +] + [[package]] name = "lqos_overrides" version = "0.1.0" @@ -2187,6 +2205,7 @@ dependencies = [ "cursive", "ip_network", "lqos_config", + "lqos_netplan_helper", "nix", "once_cell", "parking_lot", @@ -2306,6 +2325,7 @@ dependencies = [ "lqos_bus", "lqos_config", "lqos_heimdall", + "lqos_netplan_helper", "lqos_overrides", "lqos_probe", "lqos_queue_tracker", @@ -3595,6 +3615,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4389,6 +4422,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index fea0f3f5..185cf5c9 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -43,6 +43,7 @@ members = [ "lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv "lqos_topology", # Shared topology runtime domain logic and health/effective-state helper. "lqos_topology_compile", # Shared topology compilation and mode projection layer. + "lqos_netplan_helper", # Managed netplan inspection/transaction helper. ] [dependencies] diff --git a/src/rust/lqos_bus/src/bus/request.rs b/src/rust/lqos_bus/src/bus/request.rs index 69f04f1d..462cebf3 100644 --- a/src/rust/lqos_bus/src/bus/request.rs +++ b/src/rust/lqos_bus/src/bus/request.rs @@ -588,8 +588,8 @@ pub enum BusRequest { /// Retrieve current global warning list GetGlobalWarnings, - /// Is Insight Enabled? - CheckInsight, + /// Retrieve the current local LTS/license capability summary. + GetLtsCapabilities, /// Retrieve current Insight license summary (licensed + optional max circuits). GetInsightLicenseSummary, diff --git a/src/rust/lqos_bus/src/bus/response.rs b/src/rust/lqos_bus/src/bus/response.rs index 915885dd..a755e31c 100644 --- a/src/rust/lqos_bus/src/bus/response.rs +++ b/src/rust/lqos_bus/src/bus/response.rs @@ -41,6 +41,43 @@ pub struct InsightLicenseSummary { /// `None` means the license did not specify a max. pub max_circuits: Option, } + +/// Summary of the current effective LTS/license capabilities on this node. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Allocative)] +pub struct LtsCapabilitiesSummary { + /// Effective numeric license state after resolving live session or cached grant. + pub license_state: i32, + /// Human-readable effective license state label. + pub license_state_label: String, + /// Which source currently authorizes the effective state. + pub authority_label: String, + /// Whether the live control service is currently reachable and permitted. + pub control_service_reachable: bool, + /// Whether local bootstrap intent exists. + pub bootstrap_intent: bool, + /// Whether automatic bootstrap retries are currently suppressed. + pub bootstrap_suppressed: bool, + /// Whether a valid cached grant is currently available. + pub cached_grant_available: bool, + /// Whether the node may open the control channel. + pub can_open_control_channel: bool, + /// Whether Insight UI/history pages are entitled. + pub can_view_insight_ui: bool, + /// Whether the API docs/API link should be enabled. + pub can_use_api_link: bool, + /// Whether support tickets are entitled. + pub can_use_support_tickets: bool, + /// Whether chatbot/Libby is entitled. + pub can_use_chatbot: bool, + /// Whether remote commands are entitled. + pub can_receive_remote_commands: bool, + /// Whether long-term stats collection is enabled. + pub can_collect_long_term_stats: bool, + /// Whether long-term stats submission is enabled. + pub can_submit_long_term_stats: bool, + /// Effective mapped-circuit limit. `None` means unlimited. + pub mapped_circuit_limit: Option, +} /// Serializable snapshot of BakeryStats for bus transmission #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Allocative)] pub struct BakeryStatsSnapshot { @@ -683,8 +720,8 @@ pub enum BusResponse { /// Search results SearchResults(Vec), - /// Is Insight Enabled? - InsightStatus(bool), + /// Current local LTS/license capability summary. + LtsCapabilitiesSummary(LtsCapabilitiesSummary), /// Insight license summary (licensed + optional max circuits). InsightLicenseSummary(InsightLicenseSummary), diff --git a/src/rust/lqos_bus/src/lib.rs b/src/rust/lqos_bus/src/lib.rs index f0d7260d..48f9bd08 100644 --- a/src/rust/lqos_bus/src/lib.rs +++ b/src/rust/lqos_bus/src/lib.rs @@ -27,10 +27,11 @@ mod tc_handle; pub use bus::response::{ AsnHeatmapData, AsnListEntry, BakeryStatsSnapshot, CircuitCapacityRow, CircuitCount, CircuitHeatmapData, CountryListEntry, DeviceCounts, ExecutiveSummaryHeader, FlowMapPoint, - FlowTimelineEntry, InsightLicenseSummary, NodeCapacity, ProtocolListEntry, QueueStatsTotal, - RetransmitSummary, SchedulerDetails, SearchResultEntry, SiteHeatmapData, - StormguardDebugDirection, StormguardDebugEntry, TreeGuardRuntimeNodeBranchSnapshot, - TreeGuardRuntimeNodeOperationSnapshot, UrgentIssue, WarningLevel, + FlowTimelineEntry, InsightLicenseSummary, LtsCapabilitiesSummary, NodeCapacity, + ProtocolListEntry, QueueStatsTotal, RetransmitSummary, SchedulerDetails, SearchResultEntry, + SiteHeatmapData, StormguardDebugDirection, StormguardDebugEntry, + TreeGuardRuntimeNodeBranchSnapshot, TreeGuardRuntimeNodeOperationSnapshot, UrgentIssue, + WarningLevel, }; pub use bus::{ BUS_SOCKET_PATH, BakeryCapacityReportInterface, BlackboardSystem, BusClientError, BusReply, diff --git a/src/rust/lqos_netplan_helper/Cargo.toml b/src/rust/lqos_netplan_helper/Cargo.toml new file mode 100644 index 00000000..257cfc7d --- /dev/null +++ b/src/rust/lqos_netplan_helper/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lqos_netplan_helper" +version = "0.1.0" +edition = "2024" +license = "GPL-2.0-only" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +toml = { workspace = true } +default-net = { workspace = true } +clap = { workspace = true } +lqos_config = { path = "../lqos_config" } +lqos_utils = { path = "../lqos_utils" } +serde_yaml = "0.9" +libc = "0.2" +tracing = { workspace = true } diff --git a/src/rust/lqos_netplan_helper/src/inspect.rs b/src/rust/lqos_netplan_helper/src/inspect.rs new file mode 100644 index 00000000..fb012324 --- /dev/null +++ b/src/rust/lqos_netplan_helper/src/inspect.rs @@ -0,0 +1,1361 @@ +use default_net::{get_default_interface, get_interfaces}; +use lqos_config::Config; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MANAGED_NETPLAN_PATH: &str = "/etc/netplan/libreqos.yaml"; +const NETPLAN_DIR: &str = "/etc/netplan"; +const PENDING_DIR: &str = "/var/lib/libreqos/netplan-pending"; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct DetectedNetplanFile { + pub path: String, + #[serde(default)] + pub relevant_interfaces: Vec, + pub classification: String, + #[serde(default)] + pub details: Vec, + pub compatible: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct NetworkModeInspection { + pub mode_label: String, + #[serde(default)] + pub selected_interfaces: Vec, + pub inspector_state: String, + pub summary: String, + #[serde(default)] + pub warnings: Vec, + #[serde(default)] + pub dangerous_changes: Vec, + #[serde(default)] + pub conflicts: Vec, + pub editing_locked: bool, + pub managed_file_path: String, + pub managed_preview_yaml: Option, + pub preview_note: Option, + pub diff_preview: Option, + pub diff_preview_label: Option, + pub can_apply: bool, + pub can_adopt: bool, + pub can_take_over: bool, + pub action_required: Option, + pub adopt_source_path: Option, + pub strong_confirmation_text: Option, + pub has_pending_try: bool, + #[serde(default)] + pub detected_files: Vec, + #[serde(default)] + pub interface_candidates: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct InterfaceCandidate { + pub name: String, + #[serde(default)] + pub details: Vec, + pub bridge_eligible: bool, + pub single_interface_eligible: bool, + #[serde(default)] + pub current_selection: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RequestedMode { + LinuxBridge { + to_internet: String, + to_network: String, + }, + XdpBridge { + to_internet: String, + to_network: String, + }, + SingleInterface { + interface: String, + }, + Unknown, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct NetplanDocument { + #[serde(default, skip_serializing_if = "NetplanNetwork::is_empty")] + network: NetplanNetwork, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct NetplanNetwork { + #[serde(default, skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + renderer: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + ethernets: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + bridges: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + bonds: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + vlans: BTreeMap, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct NetplanInterface { + #[serde( + default, + deserialize_with = "deserialize_optional_netplan_bool", + skip_serializing_if = "Option::is_none" + )] + dhcp4: Option, + #[serde( + default, + deserialize_with = "deserialize_optional_netplan_bool", + skip_serializing_if = "Option::is_none" + )] + dhcp6: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + addresses: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + gateway4: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + gateway6: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + routes: Vec, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct NetplanBridge { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + interfaces: Vec, + #[serde( + default, + deserialize_with = "deserialize_optional_netplan_bool", + skip_serializing_if = "Option::is_none" + )] + dhcp4: Option, + #[serde( + default, + deserialize_with = "deserialize_optional_netplan_bool", + skip_serializing_if = "Option::is_none" + )] + dhcp6: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + addresses: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + gateway4: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + gateway6: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + routes: Vec, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct NetplanRelationship { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + interfaces: Vec, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct NetplanVlan { + #[serde(default, skip_serializing_if = "Option::is_none")] + link: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Debug, Default)] +struct FileAssessment { + detected: DetectedNetplanFile, + has_conflict: bool, + is_complex: bool, + is_managed_candidate: bool, + is_external_candidate: bool, + dangerous_changes: Vec, +} + +fn deserialize_optional_netplan_bool<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + + match value { + serde_yaml::Value::Bool(value) => Ok(Some(value)), + serde_yaml::Value::String(raw) => { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "true" | "yes" | "on" => Ok(Some(true)), + "false" | "no" | "off" => Ok(Some(false)), + _ => Err(serde::de::Error::custom(format!( + "invalid netplan boolean value {raw:?}" + ))), + } + } + other => Err(serde::de::Error::custom(format!( + "invalid netplan boolean type: {other:?}" + ))), + } +} + +impl NetplanInterface { + fn dhcp_disabled(&self) -> bool { + self.dhcp4 == Some(false) && self.dhcp6 == Some(false) + } + + fn has_l3_config(&self) -> bool { + !self.addresses.is_empty() + || self.gateway4.is_some() + || self.gateway6.is_some() + || !self.routes.is_empty() + || self.dhcp4 == Some(true) + || self.dhcp6 == Some(true) + } +} + +impl NetplanBridge { + fn has_l3_config(&self) -> bool { + !self.addresses.is_empty() + || self.gateway4.is_some() + || self.gateway6.is_some() + || !self.routes.is_empty() + || self.dhcp4 == Some(true) + || self.dhcp6 == Some(true) + } +} + +impl NetplanNetwork { + fn is_empty(&self) -> bool { + self.version.is_none() + && self.renderer.is_none() + && self.ethernets.is_empty() + && self.bridges.is_empty() + && self.bonds.is_empty() + && self.vlans.is_empty() + && self.extra.is_empty() + } +} + +fn has_default_route(routes: &[serde_yaml::Value]) -> bool { + routes.iter().any(|route| { + route + .as_mapping() + .and_then(|mapping| mapping.get(serde_yaml::Value::String("to".to_string()))) + .and_then(serde_yaml::Value::as_str) + .is_some_and(|to| to == "default" || to == "0.0.0.0/0" || to == "::/0") + }) +} + +fn interface_management_risk(iface: &str, cfg: &NetplanInterface) -> Option { + if cfg.gateway4.is_some() + || cfg.gateway6.is_some() + || has_default_route(&cfg.routes) + || cfg.dhcp4 == Some(true) + || cfg.dhcp6 == Some(true) + { + Some(format!( + "Selected interface {iface} appears to be on the current management/default-route path." + )) + } else if !cfg.addresses.is_empty() { + Some(format!( + "Selected interface {iface} already has static IP addressing in netplan." + )) + } else { + None + } +} + +fn bridge_management_risk(bridge_name: &str, bridge: &NetplanBridge) -> Option { + if bridge.gateway4.is_some() + || bridge.gateway6.is_some() + || has_default_route(&bridge.routes) + || bridge.dhcp4 == Some(true) + || bridge.dhcp6 == Some(true) + { + Some(format!( + "Bridge {bridge_name} appears to be on the current management/default-route path." + )) + } else if !bridge.addresses.is_empty() { + Some(format!( + "Bridge {bridge_name} already has static IP addressing in netplan." + )) + } else { + None + } +} + +fn requested_mode(config: &Config) -> RequestedMode { + if let Some(bridge) = &config.bridge { + if bridge.use_xdp_bridge { + RequestedMode::XdpBridge { + to_internet: bridge.to_internet.trim().to_string(), + to_network: bridge.to_network.trim().to_string(), + } + } else { + RequestedMode::LinuxBridge { + to_internet: bridge.to_internet.trim().to_string(), + to_network: bridge.to_network.trim().to_string(), + } + } + } else if let Some(single) = &config.single_interface { + RequestedMode::SingleInterface { + interface: single.interface.trim().to_string(), + } + } else { + RequestedMode::Unknown + } +} + +fn mode_label(mode: &RequestedMode) -> String { + match mode { + RequestedMode::LinuxBridge { .. } => "Linux Bridge".to_string(), + RequestedMode::XdpBridge { .. } => "XDP Bridge".to_string(), + RequestedMode::SingleInterface { .. } => "Single Interface".to_string(), + RequestedMode::Unknown => "Unconfigured".to_string(), + } +} + +fn selected_interfaces(mode: &RequestedMode) -> Vec { + match mode { + RequestedMode::LinuxBridge { + to_internet, + to_network, + } + | RequestedMode::XdpBridge { + to_internet, + to_network, + } => [to_internet.clone(), to_network.clone()] + .into_iter() + .filter(|iface| !iface.is_empty()) + .collect(), + RequestedMode::SingleInterface { interface } => { + if interface.is_empty() { + Vec::new() + } else { + vec![interface.clone()] + } + } + RequestedMode::Unknown => Vec::new(), + } +} + +fn managed_preview_yaml(mode: &RequestedMode) -> (Option, Option) { + match mode { + RequestedMode::LinuxBridge { + to_internet, + to_network, + } 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" + )), + 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" + )), + None, + ), + RequestedMode::XdpBridge { .. } => ( + None, + Some( + "XDP bridge mode remains a manual workflow. LibreQoS does not generate netplan for this mode." + .to_string(), + ), + ), + RequestedMode::Unknown => ( + None, + Some("LibreQoS does not have a complete network-mode configuration yet.".to_string()), + ), + _ => ( + None, + Some("Select the required interfaces to generate a managed netplan preview.".to_string()), + ), + } +} + +fn system_interfaces() -> BTreeSet { + get_interfaces() + .into_iter() + .map(|iface| iface.name) + .collect() +} + +fn supports_multi_queue(interface: &str) -> bool { + let path = Path::new("/sys/class/net").join(interface).join("queues"); + let Ok(entries) = fs::read_dir(path) else { + return false; + }; + + let mut rx = 0usize; + let mut tx = 0usize; + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if name.starts_with("rx-") { + rx += 1; + } else if name.starts_with("tx-") { + tx += 1; + } + } + + rx > 1 && tx > 1 +} + +fn parse_netplan_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .map_err(|err| format!("Unable to read {}: {err}", path.display()))?; + serde_yaml::from_str::(&raw) + .map_err(|err| format!("Unable to parse {}: {err}", path.display())) +} + +fn pending_try_exists(path: &Path) -> bool { + let Ok(entries) = fs::read_dir(path) else { + return false; + }; + entries.flatten().any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| !name.starts_with('.')) + }) +} + +fn add_interface_reason( + restrictions: &mut BTreeMap>, + iface: &str, + reason: impl Into, +) { + restrictions + .entry(iface.to_string()) + .or_default() + .insert(reason.into()); +} + +fn collect_interface_restrictions( + doc: &NetplanDocument, + restrictions: &mut BTreeMap>, +) { + for (iface, cfg) in &doc.network.ethernets { + if cfg.has_l3_config() { + add_interface_reason( + restrictions, + iface, + "Carries DHCP, static addressing, or routes in current netplan.", + ); + } + } + + for (bridge_name, bridge) in &doc.network.bridges { + if bridge.has_l3_config() { + for member in &bridge.interfaces { + add_interface_reason( + restrictions, + member, + format!( + "Member of bridge {bridge_name}, which carries DHCP, static addressing, or routes." + ), + ); + } + } + } +} + +fn interface_name_role_reason(iface: &str) -> Option<&'static str> { + if iface == "lo" { + Some("Loopback interface.") + } else if iface.contains('.') || iface.contains(':') { + Some("VLAN or alias interface.") + } else if [ + "br", + "bond", + "docker", + "veth", + "virbr", + "ifb", + "wg", + "tun", + "tap", + "tailscale", + "zt", + "vmnet", + ] + .iter() + .any(|prefix| iface.starts_with(prefix)) + { + Some("Virtual, bridge, or tunnel interface.") + } else { + None + } +} + +fn build_interface_candidates( + system_ifaces: &BTreeSet, + queue_caps: &BTreeMap, + selected: &[String], + default_interface_name: Option<&str>, + interface_restrictions: &BTreeMap>, +) -> Vec { + let selected_set = selected.iter().cloned().collect::>(); + let mut candidates = system_ifaces + .iter() + .map(|iface| { + let mut details = Vec::new(); + + if let Some(reason) = interface_name_role_reason(iface) { + details.push(reason.to_string()); + } + if default_interface_name.is_some_and(|default_iface| default_iface == iface) { + details.push("Current management/default-route interface.".to_string()); + } + if !queue_caps.get(iface).copied().unwrap_or(false) { + details.push("Does not appear to have multi-queue RX/TX support.".to_string()); + } + if let Some(extra) = interface_restrictions.get(iface) { + details.extend(extra.iter().cloned()); + } + details.sort(); + details.dedup(); + + let eligible = details.is_empty(); + InterfaceCandidate { + name: iface.clone(), + details, + bridge_eligible: eligible, + single_interface_eligible: eligible, + current_selection: selected_set.contains(iface), + } + }) + .collect::>(); + + candidates.sort_by(|left, right| { + right + .bridge_eligible + .cmp(&left.bridge_eligible) + .then_with(|| left.name.cmp(&right.name)) + }); + candidates +} + +fn read_text_if_exists(path: &Path) -> Option { + fs::read_to_string(path).ok() +} + +fn simple_diff(old_text: &str, new_text: &str) -> String { + let old_lines: Vec<&str> = old_text.lines().collect(); + let new_lines: Vec<&str> = new_text.lines().collect(); + let max_len = old_lines.len().max(new_lines.len()); + let mut output = String::new(); + for idx in 0..max_len { + match (old_lines.get(idx), new_lines.get(idx)) { + (Some(old_line), Some(new_line)) if old_line == new_line => { + output.push(' '); + output.push_str(old_line); + output.push('\n'); + } + (Some(old_line), Some(new_line)) => { + output.push('-'); + output.push_str(old_line); + output.push('\n'); + output.push('+'); + output.push_str(new_line); + output.push('\n'); + } + (Some(old_line), None) => { + output.push('-'); + output.push_str(old_line); + output.push('\n'); + } + (None, Some(new_line)) => { + output.push('+'); + output.push_str(new_line); + output.push('\n'); + } + (None, None) => {} + } + } + output +} + +pub(crate) fn adoption_rewrite_for_path(path: &Path, config: &Config) -> Result { + let mut doc = parse_netplan_file(path)?; + let mode = requested_mode(config); + + match mode { + RequestedMode::LinuxBridge { + to_internet, + to_network, + } => { + let mut removed_bridge = false; + doc.network.ethernets.remove(&to_internet); + doc.network.ethernets.remove(&to_network); + let selected = BTreeSet::from([to_internet.clone(), to_network.clone()]); + doc.network.bridges.retain(|_, bridge| { + let members = bridge.interfaces.iter().cloned().collect::>(); + let keep = members != selected; + if !keep { + removed_bridge = true; + } + keep + }); + if !removed_bridge { + return Err(format!( + "Unable to find the matching bridge in {} for adoption.", + path.display() + )); + } + } + RequestedMode::SingleInterface { interface } => { + if doc.network.ethernets.remove(&interface).is_none() { + return Err(format!( + "Unable to find interface {interface} in {} for adoption.", + path.display() + )); + } + } + RequestedMode::XdpBridge { .. } | RequestedMode::Unknown => { + return Err( + "Adoption is only supported for managed Linux bridge and single-interface modes." + .to_string(), + ); + } + } + + if doc.network.version.is_none() { + doc.network.version = Some(2); + } + + serde_yaml::to_string(&doc).map_err(|err| { + format!( + "Unable to serialize rewritten netplan {}: {err}", + path.display() + ) + }) +} + +fn assess_file(path: &Path, doc: &NetplanDocument, mode: &RequestedMode) -> FileAssessment { + let mut details = Vec::new(); + let mut relevant = BTreeSet::new(); + let mut has_conflict = false; + let mut is_complex = false; + let mut is_external_candidate = false; + let mut dangerous_changes = Vec::new(); + + match mode { + RequestedMode::LinuxBridge { + to_internet, + to_network, + } => { + for iface in [to_internet, to_network] { + if let Some(cfg) = doc.network.ethernets.get(iface) { + relevant.insert(iface.clone()); + if cfg.dhcp_disabled() { + details.push(format!("{iface} has dhcp4/dhcp6 disabled.")); + } + if cfg.has_l3_config() { + details.push(format!( + "{iface} carries DHCP, static addressing, or routes in this file." + )); + if let Some(risk) = interface_management_risk(iface, cfg) { + dangerous_changes.push(risk); + } + } + } + } + + for (bridge_name, bridge) in &doc.network.bridges { + let members: BTreeSet<_> = bridge.interfaces.iter().cloned().collect(); + let touches_selected = + members.contains(to_internet.as_str()) || members.contains(to_network.as_str()); + if !touches_selected { + continue; + } + + relevant.insert(to_internet.clone()); + relevant.insert(to_network.clone()); + details.push(format!( + "Bridge {bridge_name} contains interfaces: {}.", + bridge.interfaces.join(", ") + )); + + if members.contains(to_internet.as_str()) + && members.contains(to_network.as_str()) + && bridge.interfaces.len() == 2 + && let (Some(internet_cfg), Some(network_cfg)) = ( + doc.network.ethernets.get(to_internet), + doc.network.ethernets.get(to_network), + ) + && internet_cfg.dhcp_disabled() + && network_cfg.dhcp_disabled() + && !internet_cfg.has_l3_config() + && !network_cfg.has_l3_config() + { + details.push(format!( + "Bridge {bridge_name} matches the selected Linux bridge interfaces." + )); + is_external_candidate = true; + } else { + details.push(format!( + "Selected shaping interfaces are already part of bridge {bridge_name}, but not in the expected two-interface layout." + )); + has_conflict = true; + } + + if bridge.has_l3_config() { + details.push(format!( + "Bridge {bridge_name} also carries DHCP, addresses, or routes." + )); + if let Some(risk) = bridge_management_risk(bridge_name, bridge) { + dangerous_changes.push(risk); + } + } + } + } + RequestedMode::SingleInterface { interface } => { + if let Some(cfg) = doc.network.ethernets.get(interface) { + relevant.insert(interface.clone()); + if cfg.dhcp_disabled() && !cfg.has_l3_config() { + details.push(format!( + "{interface} already has dhcp4/dhcp6 disabled with no extra addressing." + )); + is_external_candidate = true; + } + if cfg.has_l3_config() { + details.push(format!( + "{interface} carries DHCP, static addressing, or routes in this file." + )); + if let Some(risk) = interface_management_risk(interface, cfg) { + dangerous_changes.push(risk); + } + } + } + + for (bridge_name, bridge) in &doc.network.bridges { + if bridge.interfaces.iter().any(|member| member == interface) { + relevant.insert(interface.clone()); + details.push(format!( + "{interface} is already a member of bridge {bridge_name}." + )); + has_conflict = true; + } + } + } + RequestedMode::XdpBridge { + to_internet, + to_network, + } => { + for iface in [to_internet, to_network] { + if doc.network.ethernets.contains_key(iface) { + relevant.insert(iface.clone()); + } + } + for (bridge_name, bridge) in &doc.network.bridges { + let members: BTreeSet<_> = bridge.interfaces.iter().cloned().collect(); + if members.contains(to_internet.as_str()) || members.contains(to_network.as_str()) { + relevant.insert(to_internet.clone()); + relevant.insert(to_network.clone()); + details.push(format!( + "Bridge {bridge_name} already contains one or more selected interfaces." + )); + } + } + } + RequestedMode::Unknown => {} + } + + for (bond_name, bond) in &doc.network.bonds { + for iface in selected_interfaces(mode) { + if bond.interfaces.iter().any(|member| member == &iface) { + relevant.insert(iface.clone()); + details.push(format!("{iface} is already part of bond {bond_name}.")); + has_conflict = true; + is_complex = true; + } + } + } + + for (vlan_name, vlan) in &doc.network.vlans { + for iface in selected_interfaces(mode) { + if vlan.link.as_deref() == Some(iface.as_str()) { + relevant.insert(iface.clone()); + details.push(format!( + "{iface} is already the lower interface for VLAN {vlan_name}." + )); + has_conflict = true; + is_complex = true; + } + } + } + + let classification = if is_complex { + "ComplexUnsupported" + } else if path.ends_with("libreqos.yaml") && is_external_candidate { + "ManagedByLibreQoS" + } else if is_external_candidate { + "ExternalCompatible" + } else if has_conflict { + "Conflict" + } else if relevant.is_empty() { + "Unrelated" + } else { + "Relevant" + }; + + FileAssessment { + detected: DetectedNetplanFile { + path: path.display().to_string(), + relevant_interfaces: relevant.into_iter().collect(), + classification: classification.to_string(), + details, + compatible: is_external_candidate, + }, + has_conflict, + is_complex, + is_managed_candidate: path.ends_with("libreqos.yaml") && is_external_candidate, + is_external_candidate: !path.ends_with("libreqos.yaml") && is_external_candidate, + dangerous_changes, + } +} + +pub fn inspect_network_mode_with_paths( + config: &Config, + netplan_dir: &Path, + pending_dir: &Path, + system_ifaces: &BTreeSet, + queue_caps: &BTreeMap, +) -> NetworkModeInspection { + let mode = requested_mode(config); + let selected = selected_interfaces(&mode); + let (preview, preview_note) = managed_preview_yaml(&mode); + let mut warnings = Vec::new(); + let mut dangerous_changes = Vec::new(); + let mut conflicts = Vec::new(); + let mut detected_files = Vec::new(); + let mut has_complex = false; + let mut has_managed = false; + let mut has_external = false; + let mut external_sources = Vec::new(); + let mut takeover_candidate = false; + let has_pending_try = pending_try_exists(pending_dir); + let default_interface_name = get_default_interface().ok().map(|iface| iface.name); + let mut interface_restrictions = BTreeMap::>::new(); + + if selected.is_empty() { + warnings.push( + "Select the required interfaces before applying managed bridge checks.".to_string(), + ); + } + + let mut missing = Vec::new(); + for iface in &selected { + if !system_ifaces.contains(iface) { + missing.push(iface.clone()); + } else if !queue_caps.get(iface).copied().unwrap_or(false) { + warnings.push(format!( + "Interface {iface} does not appear to have multi-queue RX/TX support." + )); + } + } + + let mut entries: Vec = match fs::read_dir(netplan_dir) { + Ok(read_dir) => read_dir.flatten().map(|entry| entry.path()).collect(), + Err(err) => { + warnings.push(format!("Unable to read {}: {err}", netplan_dir.display())); + Vec::new() + } + }; + entries.sort(); + + for path in entries { + if path.extension().and_then(|ext| ext.to_str()) != Some("yaml") { + continue; + } + + match parse_netplan_file(&path) { + Ok(doc) => { + collect_interface_restrictions(&doc, &mut interface_restrictions); + let assessment = assess_file(&path, &doc, &mode); + if assessment.has_conflict { + conflicts.extend(assessment.detected.details.clone()); + } + dangerous_changes.extend(assessment.dangerous_changes.clone()); + if assessment.is_complex { + has_complex = true; + } + if assessment.is_managed_candidate { + has_managed = true; + } + if assessment.is_external_candidate { + has_external = true; + external_sources.push(path.display().to_string()); + } + if path.ends_with("libreqos.yaml") + && assessment.detected.classification != "ManagedByLibreQoS" + && assessment.detected.classification != "Unrelated" + { + takeover_candidate = true; + } + if assessment.detected.classification != "Unrelated" { + detected_files.push(assessment.detected); + } + } + Err(err) => { + has_complex = true; + detected_files.push(DetectedNetplanFile { + path: path.display().to_string(), + relevant_interfaces: Vec::new(), + classification: "ComplexUnsupported".to_string(), + details: vec![err], + compatible: false, + }); + } + } + } + + dangerous_changes.sort(); + dangerous_changes.dedup(); + + let can_take_over = takeover_candidate && !has_pending_try; + let can_adopt = external_sources.len() == 1 && !has_complex && !has_pending_try; + if external_sources.len() > 1 { + warnings.push( + "Multiple external netplan files match the selected interfaces. Adoption stays locked until the config is simplified." + .to_string(), + ); + } + + let action_required = if can_take_over { + Some("TakeOver".to_string()) + } else if has_external { + Some("Adopt".to_string()) + } else { + None + }; + + let diff_preview_label; + let diff_preview = if let Some(preview_yaml) = preview.as_ref() { + if let Some(source_path) = external_sources.first() { + let path = PathBuf::from(source_path); + diff_preview_label = Some(format!("{source_path} -> {MANAGED_NETPLAN_PATH}")); + Some(simple_diff( + &read_text_if_exists(&path).unwrap_or_default(), + preview_yaml, + )) + } else { + let path = PathBuf::from(MANAGED_NETPLAN_PATH); + diff_preview_label = Some(format!("{MANAGED_NETPLAN_PATH} -> managed preview")); + Some(simple_diff( + &read_text_if_exists(&path).unwrap_or_default(), + preview_yaml, + )) + } + } else { + diff_preview_label = None; + None + }; + + let (inspector_state, summary, editing_locked) = if has_pending_try { + ( + "PendingTry", + "A pending network change was detected. Confirm or revert that change before editing network mode.".to_string(), + true, + ) + } else if !missing.is_empty() { + ( + "Missing", + format!( + "Selected interface{} {} {} not present on this system.", + if missing.len() == 1 { "" } else { "s" }, + missing.join(", "), + if missing.len() == 1 { "is" } else { "are" } + ), + false, + ) + } else if has_managed { + ( + "ManagedByLibreQoS", + format!( + "{} is already defined in {}.", + mode_label(&mode), + MANAGED_NETPLAN_PATH + ), + false, + ) + } else if can_take_over { + ( + "Conflict", + format!( + "{} already exists but does not look LibreQoS-managed for the selected interfaces. Review the diff and use Take Over to proceed.", + MANAGED_NETPLAN_PATH + ), + true, + ) + } else if has_external { + ( + "ExternalCompatible", + "A compatible external netplan configuration already matches the selected interfaces. Review the diff and use Adopt into libreqos.yaml if you want LibreQoS to manage it.".to_string(), + true, + ) + } else if has_complex { + ( + "ComplexUnsupported", + "Detected netplan relationships are too complex for safe automatic management." + .to_string(), + true, + ) + } else if !conflicts.is_empty() { + ( + "Conflict", + "Selected interfaces already have conflicting DHCP, static addressing, or bridge membership in netplan.".to_string(), + false, + ) + } else { + ( + "Ready", + "No blocking netplan conflicts were detected for the selected mode. Review the managed preview before applying changes.".to_string(), + false, + ) + }; + + if matches!(mode, RequestedMode::XdpBridge { .. }) { + warnings.push( + "XDP bridge mode remains manual. LibreQoS only provides inspection for this mode in the current helper slice." + .to_string(), + ); + } + + let can_apply = !editing_locked + && !matches!( + mode, + RequestedMode::XdpBridge { .. } | RequestedMode::Unknown + ) + && !has_pending_try + && preview.is_some(); + let strong_confirmation_text = if dangerous_changes.is_empty() { + None + } else { + Some( + "This change may interrupt access to this system. LibreQoS will automatically roll back if you do not confirm within 30 seconds." + .to_string(), + ) + }; + let interface_candidates = build_interface_candidates( + system_ifaces, + queue_caps, + &selected, + default_interface_name.as_deref(), + &interface_restrictions, + ); + + NetworkModeInspection { + mode_label: mode_label(&mode), + selected_interfaces: selected, + inspector_state: inspector_state.to_string(), + summary, + warnings, + dangerous_changes, + conflicts, + editing_locked, + managed_file_path: MANAGED_NETPLAN_PATH.to_string(), + managed_preview_yaml: preview, + preview_note, + diff_preview, + diff_preview_label, + can_apply, + can_adopt, + can_take_over, + action_required, + adopt_source_path: external_sources.first().cloned(), + strong_confirmation_text, + has_pending_try, + detected_files, + interface_candidates, + } +} + +pub fn inspect_network_mode(config: &Config) -> NetworkModeInspection { + let system_ifaces = system_interfaces(); + let queue_caps = system_ifaces + .iter() + .map(|iface| (iface.clone(), supports_multi_queue(iface))) + .collect::>(); + + inspect_network_mode_with_paths( + config, + Path::new(NETPLAN_DIR), + Path::new(PENDING_DIR), + &system_ifaces, + &queue_caps, + ) +} + +#[cfg(test)] +mod tests { + use super::{adoption_rewrite_for_path, inspect_network_mode_with_paths}; + use std::collections::{BTreeMap, BTreeSet}; + + fn linux_bridge_config() -> lqos_config::Config { + lqos_config::Config { + bridge: Some(lqos_config::BridgeConfig { + use_xdp_bridge: false, + to_internet: "ens19".to_string(), + to_network: "ens20".to_string(), + }), + single_interface: None, + ..lqos_config::Config::default() + } + } + + fn single_interface_config() -> lqos_config::Config { + lqos_config::Config { + bridge: None, + single_interface: Some(lqos_config::SingleInterfaceConfig { + interface: "ens19".to_string(), + internet_vlan: 2, + network_vlan: 3, + }), + ..lqos_config::Config::default() + } + } + + fn test_env(name: &str) -> std::path::PathBuf { + let root = + std::env::temp_dir().join(format!("lqos-netplan-helper-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("netplan")).expect("create test netplan dir"); + std::fs::create_dir_all(root.join("pending")).expect("create test pending dir"); + root + } + + fn queue_caps() -> BTreeMap { + BTreeMap::from([("ens19".to_string(), true), ("ens20".to_string(), true)]) + } + + #[test] + fn detects_external_compatible_linux_bridge() { + let root = test_env("external-compatible"); + std::fs::write( + root.join("netplan").join("50-cloud-init.yaml"), + r#" +network: + version: 2 + ethernets: + ens19: + dhcp4: false + dhcp6: false + ens20: + dhcp4: false + dhcp6: false + bridges: + br-test: + interfaces: + - ens19 + - ens20 +"#, + ) + .expect("write external compatible netplan fixture"); + + 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(), + ); + + assert_eq!(inspection.inspector_state, "ExternalCompatible"); + assert!(inspection.editing_locked); + assert_eq!(inspection.detected_files.len(), 1); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn detects_documented_libreqos_bridge_with_no_booleans() { + let root = test_env("managed-no-booleans"); + std::fs::write( + root.join("netplan").join("libreqos.yaml"), + r#" +network: + version: 2 + ethernets: + ens19: + dhcp4: no + dhcp6: no + ens20: + dhcp4: no + dhcp6: no + bridges: + br0: + interfaces: + - ens19 + - ens20 +"#, + ) + .expect("write managed netplan fixture"); + + 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(), + ); + + assert_eq!(inspection.inspector_state, "ManagedByLibreQoS"); + assert!(!inspection.editing_locked); + assert_eq!(inspection.detected_files.len(), 1); + assert_eq!( + inspection.detected_files[0].classification, + "ManagedByLibreQoS" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn detects_dangerous_interface_addressing() { + let root = test_env("conflict"); + std::fs::write( + root.join("netplan").join("50-cloud-init.yaml"), + r#" +network: + version: 2 + ethernets: + ens19: + dhcp4: true +"#, + ) + .expect("write conflict netplan fixture"); + + let inspection = inspect_network_mode_with_paths( + &single_interface_config(), + &root.join("netplan"), + &root.join("pending"), + &BTreeSet::from(["ens19".to_string()]), + &BTreeMap::from([("ens19".to_string(), true)]), + ); + + assert_eq!(inspection.inspector_state, "Ready"); + assert!(inspection.conflicts.is_empty()); + assert!(!inspection.dangerous_changes.is_empty()); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn reports_management_risk_as_dangerous_not_conflict() { + let root = test_env("management-risk"); + std::fs::write( + root.join("netplan").join("50-cloud-init.yaml"), + r#" +network: + version: 2 + ethernets: + ens19: + dhcp4: true + routes: + - to: default + via: 192.0.2.1 +"#, + ) + .expect("write management-risk fixture"); + + let inspection = inspect_network_mode_with_paths( + &single_interface_config(), + &root.join("netplan"), + &root.join("pending"), + &BTreeSet::from(["ens19".to_string()]), + &BTreeMap::from([("ens19".to_string(), true)]), + ); + + assert_eq!(inspection.inspector_state, "Ready"); + assert!(inspection.conflicts.is_empty()); + assert!(!inspection.dangerous_changes.is_empty()); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn adoption_rewrite_removes_selected_bridge_members() { + let root = test_env("adoption-rewrite"); + let path = root.join("netplan").join("50-cloud-init.yaml"); + std::fs::write( + &path, + r#" +network: + version: 2 + ethernets: + ens19: + dhcp4: false + dhcp6: false + ens20: + dhcp4: false + dhcp6: false + ens30: + dhcp4: true + bridges: + br-test: + interfaces: + - ens19 + - ens20 +"#, + ) + .expect("write adoption fixture"); + + let rewritten = adoption_rewrite_for_path(&path, &linux_bridge_config()) + .expect("rewrite should succeed"); + assert!(!rewritten.contains("ens19")); + assert!(!rewritten.contains("ens20")); + assert!(rewritten.contains("ens30")); + assert!(!rewritten.contains(": null")); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn adoption_rewrite_omits_null_optional_fields() { + let root = test_env("adoption-no-nulls"); + let path = root.join("netplan").join("50-cloud-init.yaml"); + std::fs::write( + &path, + r#" +network: + version: 2 + ethernets: + ens19: + dhcp4: no + dhcp6: no + ens20: + dhcp4: no + dhcp6: no + ens30: + addresses: + - 192.0.2.10/24 +"#, + ) + .expect("write adoption null fixture"); + + let rewritten = adoption_rewrite_for_path(&path, &single_interface_config()) + .expect("rewrite should succeed"); + assert!(!rewritten.contains(": null")); + assert!(!rewritten.contains("renderer: null")); + assert!(!rewritten.contains("gateway4: null")); + assert!(rewritten.contains("ens30")); + + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src/rust/lqos_netplan_helper/src/lib.rs b/src/rust/lqos_netplan_helper/src/lib.rs new file mode 100644 index 00000000..c2fcc973 --- /dev/null +++ b/src/rust/lqos_netplan_helper/src/lib.rs @@ -0,0 +1,12 @@ +//! Managed netplan inspection and transaction helper for LibreQoS. + +#![deny(clippy::unwrap_used)] + +pub mod inspect; +pub mod protocol; +pub mod transaction; + +pub use inspect::{DetectedNetplanFile, NetworkModeInspection, inspect_network_mode}; +pub use protocol::{ + ApplyMode, ApplyRequest, ApplyResponse, BackupSummary, HelperStatus, PendingOperationStatus, +}; diff --git a/src/rust/lqos_netplan_helper/src/main.rs b/src/rust/lqos_netplan_helper/src/main.rs new file mode 100644 index 00000000..e0771cf1 --- /dev/null +++ b/src/rust/lqos_netplan_helper/src/main.rs @@ -0,0 +1,109 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use lqos_netplan_helper::inspect_network_mode; +use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest}; +use lqos_netplan_helper::transaction::{ + HelperPaths, PendingChildren, apply_transaction, confirm_transaction, helper_status, + retry_shaping_transaction, revert_transaction, rollback_transaction, +}; + +#[derive(Parser)] +#[command(name = "lqos_netplan_helper")] +#[command(about = "LibreQoS managed netplan helper")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Status, + Inspect, + Preview, + Apply { + #[arg(long, default_value = "cli")] + source: String, + #[arg(long)] + operator_username: Option, + }, + Confirm { + operation_id: String, + }, + Revert { + operation_id: String, + }, + Rollback { + backup_id: String, + }, + RetryShaping, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let paths = HelperPaths::default(); + let mut pending_children = PendingChildren::default(); + match cli.command { + Commands::Status => { + let response = helper_status(&paths, &mut pending_children)?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } + Commands::Inspect => { + let config = lqos_config::load_config().context("Unable to load current config")?; + println!( + "{}", + serde_json::to_string_pretty(&inspect_network_mode(config.as_ref()))? + ); + Ok(()) + } + Commands::Preview => { + let config = lqos_config::load_config().context("Unable to load current config")?; + let inspection = inspect_network_mode(config.as_ref()); + if let Some(preview) = inspection.managed_preview_yaml { + println!("{preview}"); + } else if let Some(note) = inspection.preview_note { + println!("{note}"); + } + Ok(()) + } + Commands::Apply { + source, + operator_username, + } => { + let config = lqos_config::load_config().context("Unable to load current config")?; + let response = apply_transaction( + &paths, + &mut pending_children, + ApplyRequest { + config: (*config).clone(), + source, + operator_username, + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }, + )?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } + Commands::Confirm { operation_id } => { + let response = confirm_transaction(&paths, &mut pending_children, &operation_id)?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } + Commands::Revert { operation_id } => { + let response = revert_transaction(&paths, &mut pending_children, &operation_id)?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } + Commands::Rollback { backup_id } => { + let response = rollback_transaction(&paths, &mut pending_children, &backup_id)?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } + Commands::RetryShaping => { + let response = retry_shaping_transaction(&paths, &mut pending_children)?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) + } + } +} diff --git a/src/rust/lqos_netplan_helper/src/protocol.rs b/src/rust/lqos_netplan_helper/src/protocol.rs new file mode 100644 index 00000000..3240ba7c --- /dev/null +++ b/src/rust/lqos_netplan_helper/src/protocol.rs @@ -0,0 +1,70 @@ +use lqos_config::Config; +use serde::{Deserialize, Serialize}; + +/// Input for a staged helper apply transaction. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApplyRequest { + pub config: Config, + pub source: String, + pub operator_username: Option, + #[serde(default)] + pub mode: ApplyMode, + #[serde(default)] + pub confirm_dangerous_changes: bool, +} + +/// Apply-mode variants used by the transaction engine. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum ApplyMode { + #[default] + Apply, + Adopt, + TakeOver, +} + +/// Generic helper action response used by apply/confirm/revert/rollback/retry-shaping. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ApplyResponse { + pub ok: bool, + pub message: String, + pub operation: Option, + pub last_backup_id: Option, +} + +/// High-level helper status returned to callers. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct HelperStatus { + #[serde(default)] + pub pending_operation: Option, + #[serde(default)] + pub last_backup_id: Option, + #[serde(default)] + pub recent_backup_ids: Vec, + #[serde(default)] + pub recent_backups: Vec, +} + +/// Serializable description of a pending network change awaiting confirmation. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct PendingOperationStatus { + pub operation_id: String, + pub backup_id: String, + pub state: String, + pub source: String, + pub operator_username: Option, + pub summary: String, + pub created_unix: u64, +} + +/// Summary of a stored rollback bundle. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct BackupSummary { + pub backup_id: String, + pub timestamp_unix: u64, + pub source: String, + pub operator_username: Option, + pub old_mode: String, + pub new_mode: String, + #[serde(default)] + pub warnings_present: Vec, +} diff --git a/src/rust/lqos_netplan_helper/src/transaction.rs b/src/rust/lqos_netplan_helper/src/transaction.rs new file mode 100644 index 00000000..5b8b11f6 --- /dev/null +++ b/src/rust/lqos_netplan_helper/src/transaction.rs @@ -0,0 +1,1294 @@ +use crate::inspect::{ + NetworkModeInspection, adoption_rewrite_for_path, inspect_network_mode_with_paths, +}; +use crate::protocol::{ + ApplyMode, ApplyRequest, ApplyResponse, BackupSummary, HelperStatus, PendingOperationStatus, +}; +use anyhow::{Context, Result, anyhow, bail}; +use lqos_config::Config; +use lqos_utils::unix_time::unix_now; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; +use tracing::{info, warn}; +use uuid::Uuid; + +const BACKUP_KEEP_COUNT: usize = 5; + +/// Controls whether helper actions should trigger a LibreQoS shaping reload. +#[derive(Clone, Debug)] +pub enum RetryShapingAction { + LoadLibreQoS, + None, +} + +/// Filesystem and command paths used by the helper transaction engine. +#[derive(Clone, Debug)] +pub struct HelperPaths { + pub config_path: PathBuf, + pub netplan_dir: PathBuf, + pub managed_netplan_path: PathBuf, + pub backup_dir: PathBuf, + pub pending_dir: PathBuf, + pub netplan_bin: PathBuf, + pub netplan_timeout_secs: u32, + pub retry_shaping: RetryShapingAction, +} + +impl Default for HelperPaths { + fn default() -> Self { + Self { + config_path: PathBuf::from("/etc/lqos.conf"), + netplan_dir: PathBuf::from("/etc/netplan"), + managed_netplan_path: PathBuf::from("/etc/netplan/libreqos.yaml"), + backup_dir: PathBuf::from("/var/lib/libreqos/netplan-backups"), + pending_dir: PathBuf::from("/var/lib/libreqos/netplan-pending"), + netplan_bin: PathBuf::from("/usr/sbin/netplan"), + netplan_timeout_secs: 30, + retry_shaping: RetryShapingAction::LoadLibreQoS, + } + } +} + +/// Metadata written alongside each rollback bundle. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BackupManifest { + pub backup_id: String, + pub timestamp_unix: u64, + pub source: String, + pub operator_username: Option, + pub pending_operation_id: Option, + pub old_mode: String, + pub new_mode: String, + #[serde(default)] + pub old_interfaces: Vec, + #[serde(default)] + pub new_interfaces: Vec, + pub takeover: bool, + pub adoption: bool, + #[serde(default)] + pub files_touched: Vec, + #[serde(default)] + pub warnings_present: Vec, +} + +/// Persisted state for a pending network change awaiting LibreQoS confirmation. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PendingOperationRecord { + pub operation_id: String, + pub backup_id: String, + pub source: String, + pub operator_username: Option, + pub created_unix: u64, + pub summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, +} + +/// In-memory helper state. Pending operations are persisted on disk. +#[derive(Debug, Default)] +pub struct PendingChildren { + scheduled_rollbacks: BTreeSet, +} + +fn now_unix() -> Result { + Ok(unix_now()?) +} + +fn mode_label(config: &Config) -> String { + if let Some(bridge) = &config.bridge { + if bridge.use_xdp_bridge { + "XDP Bridge".to_string() + } else { + "Linux Bridge".to_string() + } + } else if config.single_interface.is_some() { + "Single Interface".to_string() + } else { + "Unconfigured".to_string() + } +} + +fn mode_interfaces(config: &Config) -> Vec { + if let Some(bridge) = &config.bridge { + [bridge.to_internet.trim(), bridge.to_network.trim()] + .into_iter() + .filter(|iface| !iface.is_empty()) + .map(ToOwned::to_owned) + .collect() + } else if let Some(single) = &config.single_interface { + if single.interface.trim().is_empty() { + Vec::new() + } else { + vec![single.interface.trim().to_string()] + } + } else { + Vec::new() + } +} + +fn load_config_from_path(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("Unable to read config at {}", path.display()))?; + toml::from_str::(&raw) + .with_context(|| format!("Unable to parse config at {}", path.display())) +} + +fn write_config_to_path(path: &Path, config: &Config) -> Result<()> { + let serialized = toml::to_string_pretty(config).context("Unable to serialize config")?; + fs::write(path, serialized).with_context(|| format!("Unable to write {}", path.display())) +} + +fn ensure_parent(path: &Path) -> Result<()> { + let Some(parent) = path.parent() else { + bail!("{} does not have a parent directory", path.display()); + }; + fs::create_dir_all(parent).with_context(|| format!("Unable to create {}", parent.display())) +} + +fn write_text_file(path: &Path, body: &str) -> Result<()> { + ensure_parent(path)?; + fs::write(path, body).with_context(|| format!("Unable to write {}", path.display())) +} + +fn write_netplan_file(path: &Path, body: &str) -> Result<()> { + write_text_file(path, body)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Unable to chmod 600 {}", path.display()))?; + } + Ok(()) +} + +fn system_interfaces() -> BTreeSet { + default_net::get_interfaces() + .into_iter() + .map(|iface| iface.name) + .collect() +} + +fn supports_multi_queue(interface: &str) -> bool { + let path = Path::new("/sys/class/net").join(interface).join("queues"); + let Ok(entries) = fs::read_dir(path) else { + return false; + }; + let mut rx = 0usize; + let mut tx = 0usize; + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if name.starts_with("rx-") { + rx += 1; + } else if name.starts_with("tx-") { + tx += 1; + } + } + rx > 1 && tx > 1 +} + +/// 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(); + let queue_caps = system_ifaces + .iter() + .map(|iface| (iface.clone(), supports_multi_queue(iface))) + .collect::>(); + inspect_network_mode_with_paths( + config, + &paths.netplan_dir, + &paths.pending_dir, + &system_ifaces, + &queue_caps, + ) +} + +fn backup_manifest_path(paths: &HelperPaths, backup_id: &str) -> PathBuf { + paths.backup_dir.join(backup_id).join("manifest.json") +} + +fn pending_record_path(paths: &HelperPaths, operation_id: &str) -> PathBuf { + paths.pending_dir.join(format!("{operation_id}.json")) +} + +fn read_backup_manifest(paths: &HelperPaths, backup_id: &str) -> Result { + let path = backup_manifest_path(paths, backup_id); + let raw = fs::read_to_string(&path) + .with_context(|| format!("Unable to read backup manifest {}", path.display()))?; + serde_json::from_str(&raw).context("Unable to parse backup manifest") +} + +fn backup_summary(manifest: BackupManifest) -> BackupSummary { + BackupSummary { + backup_id: manifest.backup_id, + timestamp_unix: manifest.timestamp_unix, + source: manifest.source, + operator_username: manifest.operator_username, + old_mode: manifest.old_mode, + new_mode: manifest.new_mode, + warnings_present: manifest.warnings_present, + } +} + +fn read_pending_record(paths: &HelperPaths, operation_id: &str) -> Result { + let path = pending_record_path(paths, operation_id); + let raw = fs::read_to_string(&path) + .with_context(|| format!("Unable to read pending operation {}", path.display()))?; + serde_json::from_str(&raw).context("Unable to parse pending operation") +} + +fn list_backup_ids(paths: &HelperPaths) -> Result> { + let mut ids = Vec::new(); + let Ok(entries) = fs::read_dir(&paths.backup_dir) else { + return Ok(ids); + }; + for entry in entries.flatten() { + if entry.path().is_dir() + && let Some(name) = entry.file_name().to_str() + { + ids.push(name.to_string()); + } + } + ids.sort(); + ids.reverse(); + Ok(ids) +} + +fn list_recent_backups(paths: &HelperPaths) -> Result> { + let mut backups = Vec::new(); + for backup_id in list_backup_ids(paths)?.into_iter().take(BACKUP_KEEP_COUNT) { + let manifest = read_backup_manifest(paths, &backup_id)?; + backups.push(backup_summary(manifest)); + } + Ok(backups) +} + +fn prune_backups(paths: &HelperPaths) -> Result<()> { + let ids = list_backup_ids(paths)?; + for stale in ids.into_iter().skip(BACKUP_KEEP_COUNT) { + let path = paths.backup_dir.join(stale); + fs::remove_dir_all(&path) + .with_context(|| format!("Unable to remove old backup {}", path.display()))?; + } + Ok(()) +} + +fn write_backup_bundle( + paths: &HelperPaths, + previous_config: &Config, + request: &ApplyRequest, + inspection: &NetworkModeInspection, + operation_id: &str, + external_source_path: Option<&Path>, +) -> Result { + let backup_id = Uuid::new_v4().to_string(); + let backup_root = paths.backup_dir.join(&backup_id); + fs::create_dir_all(&backup_root) + .with_context(|| format!("Unable to create {}", backup_root.display()))?; + + let previous_config_path = backup_root.join("lqos.conf.before"); + if paths.config_path.exists() { + write_config_to_path(&previous_config_path, previous_config)?; + } else { + write_text_file(&backup_root.join("lqos.conf.absent"), "absent\n")?; + } + + let previous_managed_path = backup_root.join("libreqos.yaml.before"); + if paths.managed_netplan_path.exists() { + fs::copy(&paths.managed_netplan_path, &previous_managed_path).with_context(|| { + format!( + "Unable to copy {} into backup bundle", + paths.managed_netplan_path.display() + ) + })?; + } else { + write_text_file(&backup_root.join("libreqos.yaml.absent"), "absent\n")?; + } + + if let Some(source_path) = external_source_path { + let source_backup_path = backup_root.join("adoption-source.before"); + fs::copy(source_path, &source_backup_path).with_context(|| { + format!( + "Unable to copy external adoption source {} into backup bundle", + source_path.display() + ) + })?; + write_text_file( + &backup_root.join("adoption-source.path"), + &format!("{}\n", source_path.display()), + )?; + } + + let manifest = BackupManifest { + backup_id: backup_id.clone(), + timestamp_unix: now_unix()?, + source: request.source.clone(), + operator_username: request.operator_username.clone(), + pending_operation_id: Some(operation_id.to_string()), + old_mode: mode_label(previous_config), + new_mode: mode_label(&request.config), + old_interfaces: mode_interfaces(previous_config), + new_interfaces: mode_interfaces(&request.config), + takeover: request.mode == ApplyMode::TakeOver, + adoption: request.mode == ApplyMode::Adopt, + files_touched: { + let mut files = vec![ + paths.config_path.display().to_string(), + paths.managed_netplan_path.display().to_string(), + ]; + if let Some(source_path) = external_source_path { + files.push(source_path.display().to_string()); + } + files + }, + warnings_present: inspection + .warnings + .iter() + .chain(inspection.dangerous_changes.iter()) + .cloned() + .collect(), + }; + + let manifest_path = backup_root.join("manifest.json"); + let manifest_json = + serde_json::to_string_pretty(&manifest).context("Unable to serialize backup manifest")?; + write_text_file(&manifest_path, &manifest_json)?; + prune_backups(paths)?; + + Ok(backup_id) +} + +fn restore_backup_files(paths: &HelperPaths, backup_id: &str) -> Result<()> { + let backup_root = paths.backup_dir.join(backup_id); + let previous_config_path = backup_root.join("lqos.conf.before"); + let previous_config_absent = backup_root.join("lqos.conf.absent"); + let previous_managed_path = backup_root.join("libreqos.yaml.before"); + let absent_marker = backup_root.join("libreqos.yaml.absent"); + + if previous_config_path.exists() { + fs::copy(&previous_config_path, &paths.config_path).with_context(|| { + format!( + "Unable to restore {} from backup {}", + paths.config_path.display(), + previous_config_path.display() + ) + })?; + } else if previous_config_absent.exists() && paths.config_path.exists() { + fs::remove_file(&paths.config_path).with_context(|| { + format!( + "Unable to remove restored config {}", + paths.config_path.display() + ) + })?; + } + + if previous_managed_path.exists() { + ensure_parent(&paths.managed_netplan_path)?; + fs::copy(&previous_managed_path, &paths.managed_netplan_path).with_context(|| { + format!( + "Unable to restore {} from backup {}", + paths.managed_netplan_path.display(), + previous_managed_path.display() + ) + })?; + } else if absent_marker.exists() && paths.managed_netplan_path.exists() { + fs::remove_file(&paths.managed_netplan_path).with_context(|| { + format!( + "Unable to remove managed netplan file {}", + paths.managed_netplan_path.display() + ) + })?; + } + + let adoption_source_path_file = backup_root.join("adoption-source.path"); + let adoption_source_backup = backup_root.join("adoption-source.before"); + if adoption_source_path_file.exists() && adoption_source_backup.exists() { + let source_path = fs::read_to_string(&adoption_source_path_file).with_context(|| { + format!( + "Unable to read adoption source metadata {}", + adoption_source_path_file.display() + ) + })?; + let source_path = PathBuf::from(source_path.trim()); + ensure_parent(&source_path)?; + fs::copy(&adoption_source_backup, &source_path).with_context(|| { + format!( + "Unable to restore adoption source {} from backup {}", + source_path.display(), + adoption_source_backup.display() + ) + })?; + } + + Ok(()) +} + +fn output_suffix(stdout: &str, stderr: &str) -> String { + let mut parts = Vec::new(); + if !stdout.is_empty() { + parts.push(format!("stdout:\n{stdout}")); + } + if !stderr.is_empty() { + parts.push(format!("stderr:\n{stderr}")); + } + if parts.is_empty() { + String::new() + } else { + format!("\n{}", parts.join("\n")) + } +} + +fn describe_status(status: std::process::ExitStatus) -> String { + if let Some(code) = status.code() { + format!("exit code {code}") + } else if let Some(signal) = status.signal() { + format!("signal {signal}") + } else { + "unknown status".to_string() + } +} + +fn log_command_output(prefix: &str, stdout: &str, stderr: &str) { + if !stdout.is_empty() { + info!("{prefix} stdout:\n{stdout}"); + } + if !stderr.is_empty() { + warn!("{prefix} stderr:\n{stderr}"); + } +} + +fn run_netplan_apply(paths: &HelperPaths) -> Result<()> { + let output = Command::new(&paths.netplan_bin) + .arg("apply") + .output() + .with_context(|| format!("Unable to start {}", paths.netplan_bin.display()))?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + info!("netplan apply succeeded"); + log_command_output("netplan apply", &stdout, &stderr); + Ok(()) + } else { + warn!( + "netplan apply failed with {}", + describe_status(output.status) + ); + log_command_output("netplan apply", &stdout, &stderr); + bail!( + "netplan apply failed with {}{}", + describe_status(output.status), + output_suffix(&stdout, &stderr) + ) + } +} + +fn cleanup_pending_record(paths: &HelperPaths, operation_id: &str) { + let _ = fs::remove_file(pending_record_path(paths, operation_id)); +} + +fn write_pending_record(paths: &HelperPaths, record: &PendingOperationRecord) -> Result<()> { + let json = + serde_json::to_string_pretty(record).context("Unable to serialize pending record")?; + write_text_file(&pending_record_path(paths, &record.operation_id), &json) +} + +fn list_pending_records(paths: &HelperPaths) -> Result> { + let mut records = Vec::new(); + let Ok(entries) = fs::read_dir(&paths.pending_dir) else { + return Ok(records); + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(&path) + .with_context(|| format!("Unable to read pending operation {}", path.display()))?; + let record = serde_json::from_str(&raw).context("Unable to parse pending operation")?; + records.push(record); + } + records.sort_by_key(|record| std::cmp::Reverse(record.created_unix)); + Ok(records) +} + +fn pending_status(record: &PendingOperationRecord, state: &str) -> PendingOperationStatus { + PendingOperationStatus { + operation_id: record.operation_id.clone(), + backup_id: record.backup_id.clone(), + state: state.to_string(), + source: record.source.clone(), + operator_username: record.operator_username.clone(), + summary: record.summary.clone(), + created_unix: record.created_unix, + } +} + +fn pending_deadline_unix(paths: &HelperPaths, record: &PendingOperationRecord) -> u64 { + record + .created_unix + .saturating_add(u64::from(paths.netplan_timeout_secs)) +} + +fn schedule_rollback_worker( + paths: HelperPaths, + operation_id: String, + backup_id: String, + deadline_unix: u64, +) { + std::thread::spawn(move || { + let now = unix_now().unwrap_or(0); + if deadline_unix > now { + std::thread::sleep(Duration::from_secs(deadline_unix - now)); + } + + let Ok(record) = read_pending_record(&paths, &operation_id) else { + return; + }; + if record.backup_id != backup_id { + return; + } + + warn!( + "Pending network change {} reached its rollback deadline; restoring backup {}", + operation_id, backup_id + ); + if let Err(err) = restore_backup_files(&paths, &backup_id) { + warn!( + "Unable to restore backup {} for expired pending operation {}: {err}", + backup_id, operation_id + ); + return; + } + if let Err(err) = run_netplan_apply(&paths) { + warn!( + "Expired pending operation {} restored backup {}, but netplan apply failed: {err}", + operation_id, backup_id + ); + return; + } + if let Err(err) = retry_shaping(&paths) { + warn!( + "Expired pending operation {} restored backup {}, but shaping retry failed: {err}", + operation_id, backup_id + ); + } + cleanup_pending_record(&paths, &operation_id); + }); +} + +fn schedule_pending_rollbacks( + paths: &HelperPaths, + pending_children: &mut PendingChildren, +) -> Result<()> { + let records = list_pending_records(paths)?; + let live_ids = records + .iter() + .map(|record| record.operation_id.clone()) + .collect::>(); + pending_children + .scheduled_rollbacks + .retain(|operation_id| live_ids.contains(operation_id)); + + let now = now_unix()?; + for record in records { + if now >= pending_deadline_unix(paths, &record) + || pending_children + .scheduled_rollbacks + .contains(&record.operation_id) + { + continue; + } + schedule_rollback_worker( + paths.clone(), + record.operation_id.clone(), + record.backup_id.clone(), + pending_deadline_unix(paths, &record), + ); + pending_children + .scheduled_rollbacks + .insert(record.operation_id.clone()); + } + Ok(()) +} + +fn normalize_pending_children( + paths: &HelperPaths, + pending_children: &mut PendingChildren, +) -> Result<()> { + let now = now_unix()?; + for record in list_pending_records(paths)? { + if now < pending_deadline_unix(paths, &record) { + continue; + } + warn!( + "Pending network change {} expired; restoring backup {}", + record.operation_id, record.backup_id + ); + restore_backup_files(paths, &record.backup_id)?; + if let Err(err) = run_netplan_apply(paths) { + bail!( + "Pending network change {} expired and backup {} was restored, but netplan apply failed: {err}", + record.operation_id, + record.backup_id + ); + } + cleanup_pending_record(paths, &record.operation_id); + } + schedule_pending_rollbacks(paths, pending_children)?; + Ok(()) +} + +fn retry_shaping(paths: &HelperPaths) -> Result<()> { + if let RetryShapingAction::LoadLibreQoS = paths.retry_shaping { + lqos_config::load_libreqos() + .map(|_| ()) + .map_err(|err| anyhow!("Unable to retry LibreQoS shaping: {err}"))?; + } + Ok(()) +} + +/// Return current pending-operation and rollback-bundle status for the helper. +pub fn helper_status( + paths: &HelperPaths, + pending_children: &mut PendingChildren, +) -> Result { + normalize_pending_children(paths, pending_children)?; + let pending_operation = list_pending_records(paths)? + .into_iter() + .next() + .map(|record| pending_status(&record, "PendingTry")); + + let recent_backups = list_recent_backups(paths)?; + Ok(HelperStatus { + pending_operation, + last_backup_id: recent_backups + .first() + .map(|backup| backup.backup_id.clone()), + recent_backup_ids: recent_backups + .iter() + .map(|backup| backup.backup_id.clone()) + .collect(), + recent_backups, + }) +} + +fn dangerous_change_error(inspection: &NetworkModeInspection) -> anyhow::Error { + let mut message = + String::from("Strong confirmation is required before applying this network change:"); + for warning in &inspection.dangerous_changes { + message.push_str("\n- "); + message.push_str(warning); + } + if let Some(text) = &inspection.strong_confirmation_text { + message.push_str("\n\n"); + message.push_str(text); + } + anyhow!(message) +} + +fn validate_apply_request( + request: &ApplyRequest, + inspection: &NetworkModeInspection, + previous_config: &Config, +) -> Result<()> { + if !inspection.dangerous_changes.is_empty() && !request.confirm_dangerous_changes { + return Err(dangerous_change_error(inspection)); + } + + if mode_label(previous_config) != mode_label(&request.config) + && !request.confirm_dangerous_changes + { + bail!( + "Switching between {} and {} requires strong confirmation.", + mode_label(previous_config), + mode_label(&request.config) + ); + } + + match request.mode { + ApplyMode::Apply => { + if inspection.can_take_over { + bail!( + "Take Over is required before LibreQoS can manage the existing libreqos.yaml." + ); + } + if inspection.can_adopt { + bail!( + "Adopt into libreqos.yaml is required before LibreQoS can manage the external compatible netplan file." + ); + } + if inspection.inspector_state != "Ready" + && inspection.inspector_state != "ManagedByLibreQoS" + { + bail!("{}", inspection.summary); + } + } + ApplyMode::Adopt => { + if !inspection.can_adopt { + bail!("Adoption is not available for the current netplan state."); + } + } + ApplyMode::TakeOver => { + if !inspection.can_take_over { + bail!("Take Over is not available for the current netplan state."); + } + } + } + + Ok(()) +} + +/// Stage and apply a managed LibreQoS network-mode transaction. +pub fn apply_transaction( + paths: &HelperPaths, + pending_children: &mut PendingChildren, + request: ApplyRequest, +) -> Result { + normalize_pending_children(paths, pending_children)?; + if !list_pending_records(paths)?.is_empty() { + bail!("A pending network change already exists. Confirm or revert it first."); + } + + let previous_config = if paths.config_path.exists() { + load_config_from_path(&paths.config_path)? + } else { + Config::default() + }; + let inspection = inspect_with_paths(paths, &request.config); + validate_apply_request(&request, &inspection, &previous_config)?; + + let preview_yaml = inspection.managed_preview_yaml.as_ref().ok_or_else(|| { + anyhow!( + "{}", + inspection.preview_note.clone().unwrap_or_else(|| { + "Managed netplan preview is not available for this mode.".to_string() + }) + ) + })?; + + fs::create_dir_all(&paths.backup_dir) + .with_context(|| format!("Unable to create {}", paths.backup_dir.display()))?; + fs::create_dir_all(&paths.pending_dir) + .with_context(|| format!("Unable to create {}", paths.pending_dir.display()))?; + + let external_source_path = if request.mode == ApplyMode::Adopt { + Some(PathBuf::from( + inspection.adopt_source_path.clone().ok_or_else(|| { + anyhow!("No external netplan source was identified for adoption.") + })?, + )) + } else { + None + }; + + let operation_id = Uuid::new_v4().to_string(); + let backup_id = write_backup_bundle( + paths, + &previous_config, + &request, + &inspection, + &operation_id, + external_source_path.as_deref(), + )?; + + let write_result = (|| -> Result<()> { + write_config_to_path(&paths.config_path, &request.config)?; + if let Some(source_path) = external_source_path.as_ref() { + let rewritten = adoption_rewrite_for_path(source_path, &request.config) + .map_err(|err| anyhow!("{err}"))?; + write_netplan_file(source_path, &rewritten)?; + } + write_netplan_file(&paths.managed_netplan_path, preview_yaml)?; + Ok(()) + })(); + if let Err(err) = write_result { + restore_backup_files(paths, &backup_id)?; + let _ = run_netplan_apply(paths); + return Err(err); + } + if let Err(err) = run_netplan_apply(paths) { + restore_backup_files(paths, &backup_id)?; + let rollback_result = run_netplan_apply(paths); + return Err(match rollback_result { + Ok(()) => anyhow!("Network changes were written, but netplan apply failed: {err}"), + Err(rollback_err) => anyhow!( + "Network changes were written, netplan apply failed, and rollback apply also failed: {err}; rollback error: {rollback_err}" + ), + }); + } + + let record = PendingOperationRecord { + operation_id: operation_id.clone(), + backup_id: backup_id.clone(), + source: request.source.clone(), + operator_username: request.operator_username.clone(), + created_unix: now_unix()?, + summary: match request.mode { + ApplyMode::Apply => "Network changes were applied. Confirm within 30 seconds or LibreQoS will roll back.".to_string(), + ApplyMode::Adopt => "Adopted the compatible external netplan config into libreqos.yaml. Confirm within 30 seconds or LibreQoS will roll back.".to_string(), + ApplyMode::TakeOver => "Took over the existing libreqos.yaml. Confirm within 30 seconds or LibreQoS will roll back.".to_string(), + }, + pid: None, + }; + if let Err(err) = write_pending_record(paths, &record) { + restore_backup_files(paths, &backup_id)?; + let rollback_result = run_netplan_apply(paths); + return Err(match rollback_result { + Ok(()) => anyhow!( + "Network changes were applied, but LibreQoS could not persist the pending confirmation state: {err}" + ), + Err(rollback_err) => anyhow!( + "Network changes were applied, LibreQoS could not persist the pending confirmation state, and rollback apply failed: {err}; rollback error: {rollback_err}" + ), + }); + } + schedule_pending_rollbacks(paths, pending_children)?; + + Ok(ApplyResponse { + ok: true, + message: "Network changes applied. Confirm within 30 seconds or LibreQoS will roll back." + .to_string(), + operation: Some(pending_status(&record, "PendingTry")), + last_backup_id: Some(backup_id), + }) +} + +/// Confirm a pending network change and optionally retry shaping. +pub fn confirm_transaction( + paths: &HelperPaths, + pending_children: &mut PendingChildren, + operation_id: &str, +) -> Result { + normalize_pending_children(paths, pending_children)?; + let record = read_pending_record(paths, operation_id)?; + cleanup_pending_record(paths, operation_id); + pending_children.scheduled_rollbacks.remove(operation_id); + + retry_shaping(paths)?; + + Ok(ApplyResponse { + ok: true, + message: "Network changes confirmed.".to_string(), + operation: Some(pending_status(&record, "Confirmed")), + last_backup_id: Some(record.backup_id), + }) +} + +/// Explicitly revert a pending network change. +pub fn revert_transaction( + paths: &HelperPaths, + pending_children: &mut PendingChildren, + operation_id: &str, +) -> Result { + normalize_pending_children(paths, pending_children)?; + let record = read_pending_record(paths, operation_id)?; + restore_backup_files(paths, &record.backup_id)?; + if let Err(err) = run_netplan_apply(paths) { + bail!("Revert restored files but netplan apply failed: {err}"); + } + cleanup_pending_record(paths, operation_id); + pending_children.scheduled_rollbacks.remove(operation_id); + retry_shaping(paths)?; + + Ok(ApplyResponse { + ok: true, + message: "Network changes reverted and previous managed files restored.".to_string(), + operation: Some(pending_status(&record, "Reverted")), + last_backup_id: Some(record.backup_id), + }) +} + +/// Restore a previously recorded rollback bundle. +pub fn rollback_transaction( + paths: &HelperPaths, + pending_children: &mut PendingChildren, + backup_id: &str, +) -> Result { + normalize_pending_children(paths, pending_children)?; + if !list_pending_records(paths)?.is_empty() { + bail!("A pending network change already exists. Confirm or revert it first."); + } + + read_backup_manifest(paths, backup_id)?; + restore_backup_files(paths, backup_id)?; + + if let Err(err) = run_netplan_apply(paths) { + bail!("Rollback restored files but netplan apply failed: {err}"); + } + + retry_shaping(paths)?; + + Ok(ApplyResponse { + ok: true, + message: format!("Rollback bundle {backup_id} restored."), + operation: None, + last_backup_id: Some(backup_id.to_string()), + }) +} + +/// Trigger a LibreQoS shaping retry without changing the active netplan state. +pub fn retry_shaping_transaction( + paths: &HelperPaths, + pending_children: &mut PendingChildren, +) -> Result { + normalize_pending_children(paths, pending_children)?; + retry_shaping(paths)?; + Ok(ApplyResponse { + ok: true, + message: "LibreQoS shaping retry requested.".to_string(), + operation: None, + last_backup_id: helper_status(paths, pending_children)?.last_backup_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn helper_paths(root: &Path) -> HelperPaths { + HelperPaths { + config_path: root.join("etc/lqos.conf"), + netplan_dir: root.join("etc/netplan"), + managed_netplan_path: root.join("etc/netplan/libreqos.yaml"), + backup_dir: root.join("var/lib/libreqos/netplan-backups"), + pending_dir: root.join("var/lib/libreqos/netplan-pending"), + netplan_bin: root.join("bin/netplan"), + netplan_timeout_secs: 30, + retry_shaping: RetryShapingAction::None, + } + } + + fn base_dir(name: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("lqos-netplan-tx-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("etc/netplan")).expect("create netplan dir"); + fs::create_dir_all(root.join("bin")).expect("create bin dir"); + root + } + + fn write_netplan_stub(root: &Path) { + let script = root.join("bin/netplan"); + let log_path = root.join("netplan.log"); + let body = r#"#!/bin/bash +set -euo pipefail +echo "$*" >> "__LOG_PATH__" +cmd="$1" +shift || true +case "$cmd" in + apply) + exit 0 + ;; + *) + exit 1 + ;; +esac +"#; + fs::write( + &script, + body.replace("__LOG_PATH__", &log_path.display().to_string()), + ) + .expect("write stub netplan"); + let mut perms = fs::metadata(&script).expect("stat stub").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod stub"); + } + + fn write_config(path: &Path, config: &Config) { + write_config_to_path(path, config).expect("write config"); + } + + fn linux_bridge_config() -> Config { + Config { + bridge: Some(lqos_config::BridgeConfig { + use_xdp_bridge: false, + to_internet: "ens19".to_string(), + to_network: "ens20".to_string(), + }), + single_interface: None, + ..Config::default() + } + } + + #[test] + fn apply_confirm_and_revert_manage_pending_records() { + let root = base_dir("apply-confirm"); + write_netplan_stub(&root); + let paths = helper_paths(&root); + let mut pending = PendingChildren::default(); + let existing = linux_bridge_config(); + write_config(&paths.config_path, &existing); + + let request = ApplyRequest { + config: existing.clone(), + source: "ui".to_string(), + operator_username: Some("admin".to_string()), + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }; + + let apply = apply_transaction(&paths, &mut pending, request).expect("apply should succeed"); + assert!(apply.ok); + assert!(apply.operation.is_some()); + + let operation_id = apply + .operation + .as_ref() + .expect("pending op") + .operation_id + .clone(); + assert!(pending_record_path(&paths, &operation_id).exists()); + + let confirm = + confirm_transaction(&paths, &mut pending, &operation_id).expect("confirm should work"); + assert!(confirm.ok); + assert!(!pending_record_path(&paths, &operation_id).exists()); + + let request = ApplyRequest { + config: existing, + source: "ui".to_string(), + operator_username: Some("admin".to_string()), + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }; + let apply = apply_transaction(&paths, &mut pending, request).expect("second apply"); + let operation_id = apply + .operation + .as_ref() + .expect("pending op") + .operation_id + .clone(); + let revert = + revert_transaction(&paths, &mut pending, &operation_id).expect("revert should work"); + assert!(revert.ok); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rollback_restores_previous_files() { + let root = base_dir("rollback"); + write_netplan_stub(&root); + let paths = helper_paths(&root); + let mut pending = PendingChildren::default(); + let existing = linux_bridge_config(); + write_config(&paths.config_path, &existing); + write_text_file(&paths.managed_netplan_path, "network:\n version: 2\n") + .expect("seed managed file"); + + let mut next = existing.clone(); + next.single_interface = Some(lqos_config::SingleInterfaceConfig { + interface: "ens19".to_string(), + internet_vlan: 2, + network_vlan: 3, + }); + next.bridge = None; + + let apply = apply_transaction( + &paths, + &mut pending, + ApplyRequest { + config: next, + source: "ui".to_string(), + operator_username: Some("admin".to_string()), + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }, + ) + .expect("apply should succeed"); + let backup_id = apply + .last_backup_id + .clone() + .expect("backup id should be recorded"); + let operation_id = apply + .operation + .as_ref() + .expect("pending op") + .operation_id + .clone(); + + revert_transaction(&paths, &mut pending, &operation_id).expect("revert should work"); + + write_config( + &paths.config_path, + &Config { + single_interface: Some(lqos_config::SingleInterfaceConfig { + interface: "ens31".to_string(), + internet_vlan: 10, + network_vlan: 11, + }), + bridge: None, + ..Config::default() + }, + ); + write_text_file(&paths.managed_netplan_path, "broken: true\n").expect("mutate managed"); + + let rollback = + rollback_transaction(&paths, &mut pending, &backup_id).expect("rollback should work"); + assert!(rollback.ok); + + let restored_config = + load_config_from_path(&paths.config_path).expect("load restored config"); + assert_eq!(restored_config.bridge, existing.bridge); + assert_eq!( + fs::read_to_string(&paths.managed_netplan_path).expect("read managed"), + "network:\n version: 2\n" + ); + let log = fs::read_to_string(root.join("netplan.log")).expect("read netplan log"); + assert!(log.lines().any(|line| line == "apply")); + assert!(!log.lines().any(|line| line == "generate")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn revert_applies_restored_netplan() { + let root = base_dir("revert-apply"); + write_netplan_stub(&root); + let paths = helper_paths(&root); + let mut pending = PendingChildren::default(); + let existing = linux_bridge_config(); + write_config(&paths.config_path, &existing); + + let apply = apply_transaction( + &paths, + &mut pending, + ApplyRequest { + config: existing, + source: "ui".to_string(), + operator_username: Some("admin".to_string()), + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }, + ) + .expect("apply should succeed"); + let operation_id = apply + .operation + .as_ref() + .expect("pending op") + .operation_id + .clone(); + + let revert = + revert_transaction(&paths, &mut pending, &operation_id).expect("revert should work"); + assert!(revert.ok); + + let log = fs::read_to_string(root.join("netplan.log")).expect("read netplan log"); + assert!(log.lines().any(|line| line == "apply")); + assert!(!log.lines().any(|line| line == "generate")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn expired_pending_operation_rolls_back_on_status_check() { + let root = base_dir("expire-rollback"); + write_netplan_stub(&root); + let mut paths = helper_paths(&root); + paths.netplan_timeout_secs = 0; + let mut pending = PendingChildren::default(); + let existing = linux_bridge_config(); + write_config(&paths.config_path, &existing); + write_text_file(&paths.managed_netplan_path, "network:\n version: 2\n") + .expect("seed managed file"); + + let mut next = existing.clone(); + next.single_interface = Some(lqos_config::SingleInterfaceConfig { + interface: "ens19".to_string(), + internet_vlan: 2, + network_vlan: 3, + }); + next.bridge = None; + + let apply = apply_transaction( + &paths, + &mut pending, + ApplyRequest { + config: next, + source: "ui".to_string(), + operator_username: Some("admin".to_string()), + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }, + ) + .expect("apply should succeed"); + let operation_id = apply + .operation + .as_ref() + .expect("pending op") + .operation_id + .clone(); + + let status = helper_status(&paths, &mut pending).expect("status should expire pending"); + assert!(status.pending_operation.is_none()); + assert!(!pending_record_path(&paths, &operation_id).exists()); + + let restored_config = + load_config_from_path(&paths.config_path).expect("load restored config"); + assert_eq!(restored_config.bridge, existing.bridge); + assert_eq!( + fs::read_to_string(&paths.managed_netplan_path).expect("read managed"), + "network:\n version: 2\n" + ); + let log = fs::read_to_string(root.join("netplan.log")).expect("read netplan log"); + assert_eq!(log.lines().filter(|line| *line == "apply").count(), 2); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn apply_reports_netplan_apply_stderr_on_failure() { + let root = base_dir("apply-failure"); + let script = root.join("bin/netplan"); + let body = r#"#!/bin/bash +set -euo pipefail +cmd="$1" +shift || true +case "$cmd" in + apply) + echo "systemd-networkd could not read generated files" >&2 + exit 1 + ;; + *) + exit 1 + ;; +esac +"#; + fs::write(&script, body).expect("write failing stub"); + let mut perms = fs::metadata(&script).expect("stat stub").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod stub"); + + let paths = helper_paths(&root); + let mut pending = PendingChildren::default(); + let existing = linux_bridge_config(); + write_config(&paths.config_path, &existing); + + let err = apply_transaction( + &paths, + &mut pending, + ApplyRequest { + config: existing, + source: "ui".to_string(), + operator_username: Some("admin".to_string()), + mode: ApplyMode::Apply, + confirm_dangerous_changes: true, + }, + ) + .expect_err("apply should fail"); + + let message = err.to_string(); + assert!(message.contains("netplan apply failed")); + assert!(message.contains("systemd-networkd could not read generated files")); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/rust/lqos_python/src/device_weights.rs b/src/rust/lqos_python/src/device_weights.rs index 4650f485..72ad6f0e 100644 --- a/src/rust/lqos_python/src/device_weights.rs +++ b/src/rust/lqos_python/src/device_weights.rs @@ -114,11 +114,11 @@ fn use_lts_weights() -> bool { if !(config.long_term_stats.gather_stats && config.long_term_stats.license_key.is_some()) { return false; } - // Ask lqosd (via bus) whether Insight is actually enabled/licensed - if let Ok(responses) = crate::blocking::run_query(vec![BusRequest::CheckInsight]) { + // Ask lqosd (via bus) whether long-term stats submission is actually enabled/licensed + if let Ok(responses) = crate::blocking::run_query(vec![BusRequest::GetLtsCapabilities]) { for resp in responses.into_iter() { - if let BusResponse::InsightStatus(enabled) = resp { - return enabled; + if let BusResponse::LtsCapabilitiesSummary(summary) = resp { + return summary.can_submit_long_term_stats; } } } diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 12f268c5..9fe0a594 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -3962,12 +3962,12 @@ fn treeguard_get_node_virtual_branch_state( #[pyfunction] /// Returns whether Insight features are currently enabled in `lqosd`. pub fn is_insight_enabled() -> PyResult { - let Ok(responses) = run_query(vec![BusRequest::CheckInsight]) else { + let Ok(responses) = run_query(vec![BusRequest::GetLtsCapabilities]) else { return Ok(false); }; for resp in responses { - if let BusResponse::InsightStatus(enabled) = resp { - return Ok(enabled); + if let BusResponse::LtsCapabilitiesSummary(summary) = resp { + return Ok(summary.can_view_insight_ui); } } Ok(false) diff --git a/src/rust/lqos_setup/Cargo.toml b/src/rust/lqos_setup/Cargo.toml index f4f7f3bf..25a0634b 100644 --- a/src/rust/lqos_setup/Cargo.toml +++ b/src/rust/lqos_setup/Cargo.toml @@ -7,8 +7,9 @@ license = "GPL-2.0-only" [dependencies] cursive = "0.21.1" lqos_config = { path = "../lqos_config" } +lqos_netplan_helper = { path = "../lqos_netplan_helper" } nix.workspace = true anyhow.workspace = true once_cell = { workspace = true} ip_network = { workspace = true } -parking_lot = { workspace = true } \ No newline at end of file +parking_lot = { workspace = true } diff --git a/src/rust/lqos_setup/src/bridge_mode.rs b/src/rust/lqos_setup/src/bridge_mode.rs index a55a4707..4c37f4d8 100644 --- a/src/rust/lqos_setup/src/bridge_mode.rs +++ b/src/rust/lqos_setup/src/bridge_mode.rs @@ -17,11 +17,11 @@ pub fn bridge_mode(s: &mut Cursive) { }); let mut linux_btn = group.button( BridgeMode::Linux, - "Linux Bridge (2 interfaces) - Please set up the bridge in your netplan", + "Linux Bridge (2 interfaces) - LibreQoS will inspect and stage the managed Netplan change", ); let mut xdp_btn = group.button( BridgeMode::XDP, - "XDP Bridge (2 interfaces) - Please DO NOT have a bridge in your netplan", + "XDP Bridge (2 interfaces) - Manual Netplan workflow; do not create a bridge in Netplan", ); let mut single_btn = group.button(BridgeMode::Single, "Single Interface (1 interface)"); diff --git a/src/rust/lqos_setup/src/interfaces.rs b/src/rust/lqos_setup/src/interfaces.rs index 14ef6ead..141dd137 100644 --- a/src/rust/lqos_setup/src/interfaces.rs +++ b/src/rust/lqos_setup/src/interfaces.rs @@ -216,6 +216,7 @@ pub fn interface_menu(s: &mut Cursive) { .title("Select Interfaces") .button("OK", |s| { s.pop_layer(); + crate::preview_selected_network_mode(s); }) .full_screen(), ); diff --git a/src/rust/lqos_setup/src/main.rs b/src/rust/lqos_setup/src/main.rs index 84a8c928..e5de92bf 100644 --- a/src/rust/lqos_setup/src/main.rs +++ b/src/rust/lqos_setup/src/main.rs @@ -7,15 +7,22 @@ mod preflight; mod webusers; use std::path::Path; +use std::sync::Arc; use std::{env, fmt::Write as _}; use bandwidth::bandwidth_view; use config_builder::CURRENT_CONFIG; use cursive::{ Rect, Vec2, View, - view::{Margins, Nameable, Resizable}, + view::{Margins, Nameable, Resizable, Scrollable}, views::{Checkbox, Dialog, EditView, FixedLayout, Layer, LinearLayout, OnLayoutView, TextView}, }; +use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest}; +use lqos_netplan_helper::transaction::{ + HelperPaths, PendingChildren, apply_transaction, confirm_transaction, inspect_with_paths, + revert_transaction, +}; +use parking_lot::Mutex; const VERSION: &str = include_str!("../../../VERSION_STRING"); const DEFAULT_NETWORK_JSON: &str = include_str!("../../../network.example.json"); @@ -253,6 +260,138 @@ fn main() { ui.run(); } +fn build_candidate_config(existing: Option) -> lqos_config::Config { + let mut config = existing.unwrap_or_default(); + let new_config = CURRENT_CONFIG.lock(); + config.node_name = new_config.node_name.clone(); + config.queues.downlink_bandwidth_mbps = new_config.mbps_to_internet; + config.queues.uplink_bandwidth_mbps = new_config.mbps_to_network; + 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 { + config_builder::BridgeMode::Linux => { + config.single_interface = None; + config.bridge = Some(lqos_config::BridgeConfig { + use_xdp_bridge: false, + to_internet: new_config.to_internet.clone(), + to_network: new_config.to_network.clone(), + }); + } + config_builder::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(), + }); + } + config_builder::BridgeMode::Single => { + config.single_interface = Some(lqos_config::SingleInterfaceConfig { + interface: new_config.to_internet.clone(), + internet_vlan: new_config.internet_vlan, + network_vlan: new_config.network_vlan, + }); + config.bridge = None; + } + } + config.ip_ranges.allow_subnets = new_config.allow_subnets.clone(); + config +} + +fn inspection_report(inspection: &lqos_netplan_helper::NetworkModeInspection) -> String { + let mut report = format!( + "State: {}\n{}\n", + inspection.inspector_state, inspection.summary + ); + if !inspection.warnings.is_empty() { + report.push_str("\nWarnings:\n"); + for warning in &inspection.warnings { + let _ = writeln!(&mut report, "- {warning}"); + } + } + if !inspection.dangerous_changes.is_empty() { + report.push_str("\nStrong confirmations required:\n"); + for warning in &inspection.dangerous_changes { + let _ = writeln!(&mut report, "- {warning}"); + } + } + if !inspection.conflicts.is_empty() { + report.push_str("\nConflicts:\n"); + for conflict in &inspection.conflicts { + let _ = writeln!(&mut report, "- {conflict}"); + } + } + if let Some(preview) = &inspection.managed_preview_yaml { + report.push_str("\nManaged Preview:\n"); + report.push_str(preview); + } else if let Some(note) = &inspection.preview_note { + report.push('\n'); + report.push_str(note); + } + report +} + +pub(crate) fn preview_selected_network_mode(s: &mut cursive::Cursive) { + let existing = lqos_config::load_config().ok().map(|cfg| (*cfg).clone()); + let candidate = build_candidate_config(existing); + let inspection = inspect_with_paths(&HelperPaths::default(), &candidate); + s.add_layer( + Dialog::around(TextView::new(inspection_report(&inspection)).scrollable()) + .title("Detected Netplan State") + .button("OK", |s| { + s.pop_layer(); + }) + .full_screen(), + ); +} + +fn finish_setup( + ui: &mut cursive::Cursive, + config: &lqos_config::Config, + mut event_log: Vec, +) { + let state_root = config.resolved_state_directory(); + for category in [ + "topology", + "shaping", + "stats", + "cache", + "debug", + "quarantine", + ] { + std::fs::create_dir_all(state_root.join(category)) + .expect("Unable to create state directory"); + } + + if !network_json_exists() { + let path = Path::new(&config.lqos_directory).join("network.json"); + std::fs::write(path, DEFAULT_NETWORK_JSON).expect("Unable to write file"); + event_log.push("Network.json created.".to_string()); + } else { + event_log.push("Network.json already exists - not updated.".to_string()); + } + + if !shaped_devices_exists() { + let path = Path::new(&config.lqos_directory).join("ShapedDevices.csv"); + std::fs::write(path, DEFAULT_SHAPED_DEVICES).expect("Unable to write file"); + event_log.push("ShapedDevices.csv created.".to_string()); + } else { + event_log.push("ShapedDevices.csv already exists - not updated.".to_string()); + } + + let report = cursive::With::with(LinearLayout::vertical(), |layout| { + for line in &event_log { + layout.add_child(TextView::new(line)); + } + }); + + ui.add_layer( + Dialog::around(report) + .title("Setup Complete") + .button("OK", |ui| ui.quit()), + ); +} + fn finalize(ui: &mut cursive::Cursive) { // If we cannot load the config but a file exists, warn the user and // take a backup before proceeding to create a new config. @@ -302,8 +441,7 @@ or Cancel to exit and investigate.", fn continue_finalize(ui: &mut cursive::Cursive) { let mut event_log = Vec::new(); - // Update/Create the config file. - let mut config = if let Ok(config) = lqos_config::load_config() { + let existing_config = if let Ok(config) = lqos_config::load_config() { event_log.push("Loaded existing configuration".to_string()); (*config).clone() } else { @@ -326,98 +464,146 @@ fn continue_finalize(ui: &mut cursive::Cursive) { event_log.push("Creating new configuration".to_string()); lqos_config::Config::default() }; + let config = build_candidate_config(Some(existing_config)); + let using_helper = !matches!( + CURRENT_CONFIG.lock().bridge_mode, + config_builder::BridgeMode::XDP + ); - let new_config = CURRENT_CONFIG.lock(); - config.node_name = new_config.node_name.clone(); - config.queues.downlink_bandwidth_mbps = new_config.mbps_to_internet; - config.queues.uplink_bandwidth_mbps = new_config.mbps_to_network; - 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 { - config_builder::BridgeMode::Linux => { - config.single_interface = None; - config.bridge = Some(lqos_config::BridgeConfig { - use_xdp_bridge: false, - to_internet: new_config.to_internet.clone(), - to_network: new_config.to_network.clone(), - }); - } - config_builder::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(), - }); - } - config_builder::BridgeMode::Single => { - config.single_interface = Some(lqos_config::SingleInterfaceConfig { - interface: new_config.to_internet.clone(), - internet_vlan: new_config.internet_vlan, - network_vlan: new_config.network_vlan, - }); - config.bridge = None; + if !using_helper { + if let Err(e) = lqos_config::update_config(&config) { + event_log.push(format!("ERROR: Unable to write configuration: {e:?}")); + let msg = format!("ERROR: Unable to write configuration: {e:?}"); + ui.add_layer( + Dialog::around(TextView::new(msg)) + .title("Error") + .button("OK", |s| { + s.pop_layer(); + }), + ); + return; } + event_log.push("Configuration updated.".to_string()); + event_log.push("XDP bridge mode remains a manual Netplan workflow.".to_string()); + finish_setup(ui, &config, event_log); + return; } - config.ip_ranges.allow_subnets = new_config.allow_subnets.clone(); - if let Err(e) = lqos_config::update_config(&config) { - event_log.push(format!("ERROR: Unable to write configuration: {e:?}")); - let msg = format!("ERROR: Unable to write configuration: {e:?}"); - ui.add_layer( - Dialog::around(TextView::new(msg)) - .title("Error") + + let helper_paths = HelperPaths::default(); + let pending_children = Arc::new(Mutex::new(PendingChildren::default())); + let inspection = inspect_with_paths(&helper_paths, &config); + let requires_confirmation = !inspection.dangerous_changes.is_empty(); + let apply_mode = if inspection.can_take_over { + ApplyMode::TakeOver + } else if inspection.can_adopt { + ApplyMode::Adopt + } else { + ApplyMode::Apply + }; + let apply_request = ApplyRequest { + config: config.clone(), + source: "setup".to_string(), + operator_username: None, + mode: apply_mode, + confirm_dangerous_changes: requires_confirmation, + }; + + let response = { + let mut pending = pending_children.lock(); + apply_transaction(&helper_paths, &mut pending, apply_request) + }; + let response = match response { + Ok(response) => response, + Err(err) => { + ui.add_layer( + Dialog::around(TextView::new(format!( + "Unable to stage network-mode changes:\n{err:#}" + ))) + .title("Helper Error") .button("OK", |s| { s.pop_layer(); }), - ); - return; - } - event_log.push("Configuration updated".to_string()); - - let state_root = config.resolved_state_directory(); - for category in [ - "topology", - "shaping", - "stats", - "cache", - "debug", - "quarantine", - ] { - std::fs::create_dir_all(state_root.join(category)) - .expect("Unable to create state directory"); - } - - // Does network.json exist? - if !network_json_exists() { - let path = Path::new(&config.lqos_directory).join("network.json"); - std::fs::write(path, DEFAULT_NETWORK_JSON).expect("Unable to write file"); - event_log.push("Network.json created.".to_string()); - } else { - event_log.push("Network.json already exists - not updated.".to_string()); - } - - // Does ShapedDevices.csv exist? - if !shaped_devices_exists() { - let path = Path::new(&config.lqos_directory).join("ShapedDevices.csv"); - std::fs::write(path, DEFAULT_SHAPED_DEVICES).expect("Unable to write file"); - event_log.push("ShapedDevices.csv created.".to_string()); - } else { - event_log.push("ShapedDevices.csv already exists - not updated.".to_string()); - } - - // Display final report - use cursive::views::{Dialog, LinearLayout, TextView}; - - let report = cursive::With::with(LinearLayout::vertical(), |layout| { - for line in &event_log { - layout.add_child(TextView::new(line)); + ); + return; } - }); + }; + + let Some(operation) = response.operation.clone() else { + finish_setup(ui, &config, event_log); + return; + }; + + let confirm_paths = helper_paths.clone(); + let confirm_pending = Arc::clone(&pending_children); + let confirm_config = config.clone(); + let confirm_log = event_log.clone(); + let confirm_operation_id = operation.operation_id.clone(); + let revert_paths = helper_paths.clone(); + let revert_pending = Arc::clone(&pending_children); + let revert_operation_id = operation.operation_id.clone(); ui.add_layer( - Dialog::around(report) - .title("Setup Complete") - .button("OK", |ui| ui.quit()), + Dialog::around(TextView::new(format!( + "{}\n\n{}\n\nConfirm the change to keep the managed netplan update, or revert it now.", + response.message, + inspection_report(&inspection) + ))) + .title("Confirm Netplan Change") + .button("Confirm", move |s| { + let result = { + let mut pending = confirm_pending.lock(); + confirm_transaction(&confirm_paths, &mut pending, &confirm_operation_id) + }; + match result { + Ok(confirm) => { + let mut log = confirm_log.clone(); + log.push(confirm.message); + s.pop_layer(); + finish_setup(s, &confirm_config, log); + } + Err(err) => { + s.add_layer( + Dialog::around(TextView::new(format!( + "Unable to confirm the pending network change:\n{err:#}" + ))) + .title("Helper Error") + .button("OK", |s| { + s.pop_layer(); + }), + ); + } + } + }) + .button("Revert", move |s| { + let result = { + let mut pending = revert_pending.lock(); + revert_transaction(&revert_paths, &mut pending, &revert_operation_id) + }; + match result { + Ok(revert) => { + s.add_layer( + Dialog::around(TextView::new(revert.message)) + .title("Network Change Reverted") + .button("OK", |s| { + s.pop_layer(); + s.pop_layer(); + }), + ); + } + Err(err) => { + s.add_layer( + Dialog::around(TextView::new(format!( + "Unable to revert the pending network change:\n{err:#}" + ))) + .title("Helper Error") + .button("OK", |s| { + s.pop_layer(); + }), + ); + } + } + }) + .full_screen(), ); } diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index 9b0acdbe..aade11c2 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -21,6 +21,7 @@ lqos_topology_compile = { path = "../lqos_topology_compile" } lqos_heimdall = { path = "../lqos_heimdall" } lqos_stormguard = { path = "../lqos_stormguard" } lqos_probe = { path = "../lqos_probe" } +lqos_netplan_helper = { path = "../lqos_netplan_helper" } tokio = { version = "1", features = [ "full" ] } once_cell = { workspace = true} lqos_bus = { path = "../lqos_bus" } diff --git a/src/rust/lqosd/src/lts2_sys/capabilities.rs b/src/rust/lqosd/src/lts2_sys/capabilities.rs new file mode 100644 index 00000000..82d2a31f --- /dev/null +++ b/src/rust/lqosd/src/lts2_sys/capabilities.rs @@ -0,0 +1,227 @@ +use crate::lts2_sys::license_grant::{self, LicenseGrant}; +use crate::lts2_sys::lts2_client::get_license_status; +use crate::lts2_sys::shared_types::LtsStatus; +use lqos_bus::LtsCapabilitiesSummary; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use tokio::sync::Notify; +use uuid::Uuid; + +#[derive(Default)] +struct RuntimeLicenseState { + control_service_reachable: bool, + signup_bootstrap_active: bool, + suppressed_license_key: Option, +} + +static RUNTIME_LICENSE_STATE: Lazy> = + Lazy::new(|| Mutex::new(RuntimeLicenseState::default())); +static CONTROL_CHANNEL_NOTIFY: Lazy = Lazy::new(Notify::new); + +pub fn set_control_service_reachable(reachable: bool) { + RUNTIME_LICENSE_STATE.lock().control_service_reachable = reachable; + if reachable { + CONTROL_CHANNEL_NOTIFY.notify_waiters(); + } +} + +pub fn set_signup_bootstrap_active(active: bool) { + RUNTIME_LICENSE_STATE.lock().signup_bootstrap_active = active; + if active { + CONTROL_CHANNEL_NOTIFY.notify_waiters(); + } +} + +pub fn suppress_bootstrap_for_license_key(license_key: &str) { + let normalized = normalize_non_empty(Some(license_key)); + RUNTIME_LICENSE_STATE.lock().suppressed_license_key = normalized; +} + +pub fn clear_bootstrap_suppression() { + RUNTIME_LICENSE_STATE.lock().suppressed_license_key = None; + CONTROL_CHANNEL_NOTIFY.notify_waiters(); +} + +pub fn wake_control_channel() { + CONTROL_CHANNEL_NOTIFY.notify_waiters(); +} + +pub async fn wait_for_control_channel_retry(delay: std::time::Duration) { + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = CONTROL_CHANNEL_NOTIFY.notified() => {} + } +} + +pub fn current_capabilities() -> LtsCapabilitiesSummary { + let Ok(config) = lqos_config::load_config() else { + return LtsCapabilitiesSummary { + license_state: LtsStatus::Invalid as i32, + license_state_label: LtsStatus::Invalid.label().to_string(), + authority_label: "Unlicensed".to_string(), + control_service_reachable: false, + bootstrap_intent: false, + bootstrap_suppressed: false, + cached_grant_available: false, + can_open_control_channel: false, + can_view_insight_ui: false, + can_use_api_link: false, + can_use_support_tickets: false, + can_use_chatbot: false, + can_receive_remote_commands: false, + can_collect_long_term_stats: false, + can_submit_long_term_stats: false, + mapped_circuit_limit: Some(1000), + }; + }; + + current_capabilities_for_config(config.as_ref()) +} + +pub fn can_submit_long_term_stats() -> bool { + current_capabilities().can_submit_long_term_stats +} + +pub fn can_open_control_channel() -> bool { + current_capabilities().can_open_control_channel +} + +pub fn control_service_reachable() -> bool { + RUNTIME_LICENSE_STATE.lock().control_service_reachable +} + +fn current_capabilities_for_config(config: &lqos_config::Config) -> LtsCapabilitiesSummary { + let runtime = RUNTIME_LICENSE_STATE.lock(); + let configured_license_key = normalize_non_empty(config.long_term_stats.license_key.as_deref()); + let valid_bootstrap_key = + parse_license_uuid(config.long_term_stats.license_key.as_deref()).is_some(); + let bootstrap_intent = configured_license_key.is_some() || runtime.signup_bootstrap_active; + let bootstrap_suppressed = configured_license_key.is_some() + && configured_license_key == runtime.suppressed_license_key; + let control_service_reachable = runtime.control_service_reachable; + drop(runtime); + + let cached_grant = license_grant::current_valid_grant(); + let cached_grant_available = cached_grant.is_some(); + let live_status = LtsStatus::from_i32(get_license_status().license_type); + let live_entitled = control_service_reachable && is_entitled_status(live_status); + let effective_status = if live_entitled { + live_status + } else if let Some(grant) = cached_grant.as_ref() { + LtsStatus::from_i32(grant.license_state) + } else if bootstrap_intent { + LtsStatus::NotChecked + } else { + LtsStatus::Invalid + }; + + let authority_label = if live_entitled { + "Live license session" + } else if cached_grant_available { + "Cached signed grant" + } else if bootstrap_intent { + "Bootstrap pending" + } else { + "Unlicensed" + }; + + let can_open_control_channel = + control_service_reachable || (valid_bootstrap_key && !bootstrap_suppressed); + let mapped_circuit_limit = mapped_circuit_limit(effective_status, cached_grant.as_ref()); + let can_collect_or_submit = + config.long_term_stats.gather_stats && supports_long_term_stats(effective_status); + + LtsCapabilitiesSummary { + license_state: effective_status as i32, + license_state_label: effective_status.label().to_string(), + authority_label: authority_label.to_string(), + control_service_reachable, + bootstrap_intent, + bootstrap_suppressed, + cached_grant_available, + can_open_control_channel, + can_view_insight_ui: can_view_insight_ui(effective_status), + can_use_api_link: can_use_api_link(effective_status), + can_use_support_tickets: can_use_support_tickets(effective_status), + can_use_chatbot: can_use_chatbot(effective_status), + can_receive_remote_commands: can_receive_remote_commands(effective_status), + can_collect_long_term_stats: can_collect_or_submit, + can_submit_long_term_stats: can_collect_or_submit, + mapped_circuit_limit, + } +} + +fn mapped_circuit_limit(status: LtsStatus, cached_grant: Option<&LicenseGrant>) -> Option { + if !lifts_mapped_circuit_cap(status) { + return Some(1000); + } + + cached_grant.and_then(|grant| grant.max_circuits) +} + +fn normalize_non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn parse_license_uuid(value: Option<&str>) -> Option { + let value = normalize_non_empty(value)?; + Uuid::parse_str(&value.replace('-', "")).ok() +} + +fn is_entitled_status(status: LtsStatus) -> bool { + !matches!(status, LtsStatus::Invalid | LtsStatus::NotChecked) +} + +fn lifts_mapped_circuit_cap(status: LtsStatus) -> bool { + matches!( + status, + LtsStatus::AlwaysFree + | LtsStatus::FreeTrial + | LtsStatus::SelfHosted + | LtsStatus::ApiOnly + | LtsStatus::Full + ) +} + +fn supports_long_term_stats(status: LtsStatus) -> bool { + matches!( + status, + LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full + ) +} + +fn can_view_insight_ui(status: LtsStatus) -> bool { + matches!( + status, + LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full + ) +} + +fn can_use_api_link(status: LtsStatus) -> bool { + matches!(status, LtsStatus::ApiOnly | LtsStatus::Full) +} + +fn can_use_support_tickets(status: LtsStatus) -> bool { + matches!( + status, + LtsStatus::AlwaysFree + | LtsStatus::FreeTrial + | LtsStatus::SelfHosted + | LtsStatus::ApiOnly + | LtsStatus::Full + ) +} + +fn can_use_chatbot(status: LtsStatus) -> bool { + can_use_support_tickets(status) +} + +fn can_receive_remote_commands(status: LtsStatus) -> bool { + matches!( + status, + LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full + ) +} diff --git a/src/rust/lqosd/src/lts2_sys/control_channel.rs b/src/rust/lqosd/src/lts2_sys/control_channel.rs index 2ce468c0..ba8e1e29 100644 --- a/src/rust/lqosd/src/lts2_sys/control_channel.rs +++ b/src/rust/lqosd/src/lts2_sys/control_channel.rs @@ -10,6 +10,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tracing::{debug, error, info, warn}; use tungstenite::Message; +use crate::lts2_sys::capabilities; use crate::lts2_sys::license_grant; use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status}; use lqos_probe::ProbeClass; @@ -277,16 +278,24 @@ async fn persistent_connection( ) -> std::result::Result<(), String> { let mut sleep_seconds = 60; 'reconnect: loop { + if !capabilities::can_open_control_channel() { + capabilities::set_control_service_reachable(false); + capabilities::wait_for_control_channel_retry(Duration::from_secs(sleep_seconds)).await; + continue 'reconnect; + } + if let Ok(mut socket) = connect().await { let mut permitted = false; // Preamble - get connected if let Err(e) = send_magic_number(&mut socket).await { warn!("Failed to send magic number: {}", e); + capabilities::set_control_service_reachable(false); tokio::time::sleep(Duration::from_secs(sleep_seconds)).await; continue 'reconnect; } if let Err(e) = send_license(&mut socket).await { warn!("Failed to send license info: {}", e); + capabilities::set_control_service_reachable(false); tokio::time::sleep(Duration::from_secs(sleep_seconds)).await; continue 'reconnect; } @@ -793,6 +802,8 @@ async fn persistent_connection( license_type: license_state, trial_expires: expiration_date as i32, }); + capabilities::clear_bootstrap_suppression(); + capabilities::set_control_service_reachable(true); permitted = true; sleep_seconds = 60; // Flush any pending chatbot messages now that we're permitted @@ -825,10 +836,19 @@ async fn persistent_connection( license_type: 0, trial_expires: -1, }); - permitted = false; + capabilities::set_control_service_reachable(false); + if let Ok(config) = lqos_config::load_config() + && let Some(license_key) = + config.long_term_stats.license_key.as_deref() + { + capabilities::suppress_bootstrap_for_license_key( + license_key, + ); + } if let Err(e) = license_grant::invalidate_license_grant() { warn!("Failed to invalidate stored license grant: {e:?}"); } + break 'message_pump; } } messages::WsMessage::InsightPublicKey { public_key } => { @@ -869,7 +889,15 @@ async fn persistent_connection( } } messages::WsMessage::RemoteCommands { commands } => { - crate::lts2_sys::lts2_client::enqueue(commands); + if crate::lts2_sys::current_capabilities() + .can_receive_remote_commands + { + crate::lts2_sys::lts2_client::enqueue(commands); + } else { + warn!( + "Ignoring remote commands because the current license tier does not permit them" + ); + } } messages::WsMessage::ChatbotChunk { request_id, data } => { if let Some(tx) = chatbot_streams.get(&request_id) { @@ -1113,8 +1141,9 @@ async fn persistent_connection( } } } + capabilities::set_control_service_reachable(false); debug!("Sleeping before reconnecting the persistent channel"); - tokio::time::sleep(Duration::from_secs(sleep_seconds)).await; + capabilities::wait_for_control_channel_retry(Duration::from_secs(sleep_seconds)).await; sleep_seconds = 60; } } diff --git a/src/rust/lqosd/src/lts2_sys/license_grant.rs b/src/rust/lqosd/src/lts2_sys/license_grant.rs index 8db1b173..bd63e4a3 100644 --- a/src/rust/lqosd/src/lts2_sys/license_grant.rs +++ b/src/rust/lqosd/src/lts2_sys/license_grant.rs @@ -11,7 +11,6 @@ use tracing::{debug, info, warn}; use uuid::Uuid; use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status}; -use crate::lts2_sys::shared_types::LtsStatus; use lqos_utils::unix_time::unix_now; #[cfg(unix)] @@ -212,31 +211,20 @@ pub fn current_license_summary() -> (Option, Option) { (grant.license_uuid, grant.max_circuits) } -pub fn current_license_limits() -> (bool, Option) { - let Some(state) = GRANT_STATE.get() else { - return current_license_limits_from_status(); - }; +pub fn current_valid_grant() -> Option { + let state = GRANT_STATE.get()?; let guard = state.lock(); - let Some(grant) = guard.as_ref() else { - return current_license_limits_from_status(); - }; + let grant = guard.as_ref()?; let now = unix_now().unwrap_or(0) as i64; - if grant.grant_expires <= now { - return current_license_limits_from_status(); - } - (true, grant.max_circuits) + (grant.grant_expires > now).then(|| grant.clone()) } -fn current_license_limits_from_status() -> (bool, Option) { - let (status, _) = crate::lts2_sys::get_lts_license_status(); - let licensed = !matches!( - status, - LtsStatus::NotChecked | LtsStatus::ApiOnly | LtsStatus::Invalid - ); - if licensed { - (true, None) - } else { +pub fn current_license_limits() -> (bool, Option) { + let caps = crate::lts2_sys::capabilities::current_capabilities(); + if caps.mapped_circuit_limit == Some(1000) { (false, None) + } else { + (true, caps.mapped_circuit_limit) } } diff --git a/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor.rs b/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor.rs index ca6c5d6c..3d09c706 100644 --- a/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor.rs +++ b/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor.rs @@ -46,6 +46,9 @@ fn ingestor_loop(rx: std::sync::mpsc::Receiver) { serial += 1; } } else { + if !crate::lts2_sys::can_submit_long_term_stats() { + continue; + } let mut message_queue_lock = message_queue.lock(); message_queue_lock.ingest(msg); } diff --git a/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor/message_queue.rs b/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor/message_queue.rs index 70a7901a..9db75419 100644 --- a/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor/message_queue.rs +++ b/src/rust/lqosd/src/lts2_sys/lts2_client/ingestor/message_queue.rs @@ -111,6 +111,11 @@ impl MessageQueue { } pub(crate) fn build_chunks(&mut self) -> Result>> { + if !crate::lts2_sys::can_submit_long_term_stats() { + self.clear(); + return Ok(vec![]); + } + // Gather the license info for bundling let (license_key, node_id, node_name) = { let Ok(lock) = load_config() else { @@ -127,7 +132,6 @@ impl MessageQueue { ) }; let Ok(license_uuid) = Uuid::parse_str(&license_key.replace("-", "")) else { - warn!("Failed to parse license key"); return Ok(vec![]); }; diff --git a/src/rust/lqosd/src/lts2_sys/lts2_client/mod.rs b/src/rust/lqosd/src/lts2_sys/lts2_client/mod.rs index bff49b0d..bb09238a 100644 --- a/src/rust/lqosd/src/lts2_sys/lts2_client/mod.rs +++ b/src/rust/lqosd/src/lts2_sys/lts2_client/mod.rs @@ -450,6 +450,9 @@ pub async fn get_lts_license_trial_remaining_async() -> anyhow::Result { } pub fn ingest_batch_complete() -> anyhow::Result<()> { + if !crate::lts2_sys::can_submit_long_term_stats() { + return Ok(()); + } if let Ok(tx) = client_commands::get_command_channel() && tx.send(LtsClientCommand::IngestBatchComplete).is_err() { diff --git a/src/rust/lqosd/src/lts2_sys/mod.rs b/src/rust/lqosd/src/lts2_sys/mod.rs index 5761e08f..172d4053 100644 --- a/src/rust/lqosd/src/lts2_sys/mod.rs +++ b/src/rust/lqosd/src/lts2_sys/mod.rs @@ -1,6 +1,7 @@ //! Provides an interface with Insight/LTS2 use parking_lot::Mutex; +pub mod capabilities; pub mod license_grant; pub(crate) mod lts2_client; pub mod shared_types; @@ -12,6 +13,9 @@ use anyhow::Result; use once_cell::sync::Lazy; pub use shared_types::RemoteCommand; pub mod control_channel; +pub use capabilities::{ + can_open_control_channel, can_submit_long_term_stats, current_capabilities, +}; pub fn start_lts2( control_tx: tokio::sync::mpsc::Sender, diff --git a/src/rust/lqosd/src/lts2_sys/shared_types.rs b/src/rust/lqosd/src/lts2_sys/shared_types.rs index c2e46c18..9a14ef89 100644 --- a/src/rust/lqosd/src/lts2_sys/shared_types.rs +++ b/src/rust/lqosd/src/lts2_sys/shared_types.rs @@ -162,18 +162,19 @@ pub struct SiteRtt { #[repr(i32)] pub enum LtsStatus { NotChecked = -1, - AlwaysFree = 0, - FreeTrial = 1, - SelfHosted = 2, - ApiOnly = 3, - Full = 4, - Invalid = 5, + Invalid = 0, + AlwaysFree = 1, + FreeTrial = 2, + SelfHosted = 3, + ApiOnly = 4, + Full = 5, } impl LtsStatus { pub fn from_i32(value: i32) -> Self { match value { -1 => LtsStatus::NotChecked, + 0 => LtsStatus::Invalid, 1 => LtsStatus::AlwaysFree, 2 => LtsStatus::FreeTrial, 3 => LtsStatus::SelfHosted, @@ -182,6 +183,18 @@ impl LtsStatus { _ => LtsStatus::Invalid, } } + + pub const fn label(self) -> &'static str { + match self { + LtsStatus::NotChecked => "Not Checked", + LtsStatus::Invalid => "Invalid", + LtsStatus::AlwaysFree => "Always Free", + LtsStatus::FreeTrial => "Free Trial", + LtsStatus::SelfHosted => "Self Hosted", + LtsStatus::ApiOnly => "Local API", + LtsStatus::Full => "Insight", + } + } } #[derive(Serialize, Deserialize)] diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index bd095de8..0c1e1e2d 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -17,6 +17,7 @@ mod remote_commands; mod rtt_exclusions; mod scheduler_control; mod shaped_devices_tracker; +mod shaping_runtime; mod stats; mod stick; mod system_stats; @@ -65,8 +66,6 @@ use tracing::{error, info, warn}; use crate::blackboard::{BLACKBOARD_SENDER, BlackboardCommand}; -use crate::lts2_sys::get_lts_license_status; -use crate::lts2_sys::shared_types::LtsStatus; use crate::remote_commands::start_remote_commands; use lqos_stormguard::{STORMGUARD_DEBUG, STORMGUARD_STATS}; use tracing::level_filters::LevelFilter; @@ -155,6 +154,18 @@ fn normalize_mapping_request( Ok((base_tc_handle, base_cpu, false)) } +fn record_shaping_failure( + state: shaping_runtime::ShapingRuntimeState, + summary: impl Into, + detail: impl Into, +) { + let summary = summary.into(); + let detail = detail.into(); + error!("{summary}: {detail}"); + node_manager::add_global_warning(node_manager::WarningLevel::Error, summary.clone()); + shaping_runtime::mark_error(state, summary, detail); +} + fn main() -> Result<()> { // Set up logging set_console_logging()?; @@ -178,12 +189,10 @@ fn main() -> Result<()> { // Announce startup info!("LibreQoS Daemon Starting"); - // Run preflight checks - preflight_checks::preflight_checks()?; - // Load config let config = lqos_config::load_config()?; - let stick_offset = stick::recompute_stick_offset(&config)?; + let web_config = config.clone(); + shaping_runtime::mark_starting("LibreQoS is starting the web UI and shaping services."); ensure_rustls_crypto_provider()?; @@ -194,33 +203,17 @@ fn main() -> Result<()> { // Apply Tunings tuning::tune_lqosd_from_config_file()?; - // Start the flow tracking actor. This has to happen - // before the ringbuffer goes live. - FlowActor::start()?; - - // Start the XDP/TC kernels - let kernels = if config.on_a_stick_mode() { - LibreQoSKernels::on_a_stick_mode( - config.internet_interface(), - config.stick_vlans().1 as u16, - config.stick_vlans().0 as u16, - stick_offset, - Some(heimdall_handle_events), - Some(flowbee_handle_events), - )? - } else { - LibreQoSKernels::new( - &config.internet_interface(), - &config.isp_interface(), - Some(heimdall_handle_events), - Some(flowbee_handle_events), - )? - }; - // Start the Bakery for TC command execution - let Ok(bakery_sender) = lqos_bakery::start_bakery() else { - error!("Failed to start Bakery, exiting."); - std::process::exit(1); + let bakery_sender = match lqos_bakery::start_bakery() { + Ok(sender) => Some(sender), + Err(err) => { + record_shaping_failure( + shaping_runtime::ShapingRuntimeState::Degraded, + "LibreQoS could not start the shaping worker.", + format!("{err:#}"), + ); + None + } }; // Spawn tracking sub-systems @@ -235,32 +228,215 @@ fn main() -> Result<()> { } else { info!("Insight client started successfully"); } - blackboard::start_blackboard(); - start_remote_commands(); - let flow_tx = setup_netflow_tracker()?; - let _ = throughput_tracker::flow_data::setup_flow_analysis(); - start_heimdall()?; - spawn_queue_structure_monitor()?; - shaped_devices_tracker::shaped_devices_watcher()?; - shaped_devices_tracker::network_json_watcher()?; - shaped_devices_tracker::shaping_inputs_watcher()?; let system_usage_tx = system_stats::start_system_stats()?; - throughput_tracker::spawn_throughput_monitor( - flow_tx, - system_usage_tx.clone(), - bakery_sender.clone(), - )?; - spawn_queue_monitor()?; - - if let Err(err) = treeguard::actor::start_treeguard_actor(system_usage_tx.clone()) { - warn!("Failed to start TreeGuard actor: {err}"); - } - - lqos_sys::bpf_garbage_collector(); - version_checks::start_version_check()?; // Handle signals let mut signals = Signals::new([SIGINT, SIGHUP, SIGTERM])?; + let (stormguard_ready_tx, stormguard_ready_rx) = tokio::sync::oneshot::channel::< + Option>, + >(); + + // Create the socket server + let server = UnixSocketServer::new().expect("Unable to spawn server"); + + // Memory Debugging + memory_debug(); + + let control_tx_for_webserver = control_tx_for_web.clone(); + let system_usage_tx_for_web = system_usage_tx.clone(); + let handle = std::thread::Builder::new() + .name("Async Bus/Web".to_string()) + .spawn(move || { + let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + else { + error!("Unable to start Tokio runtime. Not much is going to work"); + return; + }; + tokio_runtime.block_on(async { + let probe_client = + lqos_probe::ProbeManager::spawn(lqos_probe::ProbeManagerConfig::default()); + let probe_client_for_stormguard = probe_client.clone(); + probe_provider::install_probe_client(probe_client.clone()); + + tokio::spawn(async move { + match lts2_sys::control_channel::start_control_channel(control_channel).await { + Ok(_) => info!("Insight control channel started successfully"), + Err(e) => error!("Insight control channel failed to start: {:#}", e), + } + }); + + tokio::spawn(async move { + match stormguard_ready_rx.await { + Ok(Some(bakery_sender_for_async)) => { + use tokio::time::{Duration, sleep}; + for _ in 0..100u32 { + if tokio::fs::metadata(lqos_bus::BUS_SOCKET_PATH).await.is_ok() { + break; + } + sleep(Duration::from_millis(50)).await; + } + if let Some(sender) = lqos_bakery::BAKERY_SENDER.get() { + let _ = sender.send(lqos_bakery::BakeryCommands::BusReady); + } + + match lqos_stormguard::start_stormguard( + bakery_sender_for_async, + shaped_devices_tracker::full_network_map_snapshot, + probe_client_for_stormguard, + ) + .await + { + Ok(_) => info!("StormGuard started successfully"), + Err(e) => error!("StormGuard failed to start: {:#}", e), + } + } + Ok(None) => { + warn!("StormGuard not started because shaping is degraded."); + } + Err(_) => { + warn!("StormGuard startup channel closed before shaping was ready."); + } + } + }); + + let (bus_tx, bus_rx) = tokio::sync::mpsc::channel::<( + tokio::sync::oneshot::Sender, + BusRequest, + )>(100); + + let webserver_disabled = web_config.disable_webserver.unwrap_or(false); + if !webserver_disabled { + let control_tx_for_webserver = control_tx_for_webserver.clone(); + let probe_client_for_webserver = probe_client.clone(); + tokio::spawn(async move { + if let Err(e) = node_manager::spawn_webserver( + bus_tx, + system_usage_tx_for_web, + control_tx_for_webserver, + probe_client_for_webserver, + ) + .await + { + error!("Node Manager Failed: {e:?}"); + } + }); + } else { + warn!("Webserver disabled by configuration"); + } + + if let Err(e) = server.listen(handle_bus_requests, bus_rx).await { + error!("Bus stopped: {e:?}"); + } + }); + })?; + + version_checks::start_version_check()?; + + let mut kernels = None; + let stormguard_ready = if bakery_sender.is_none() { + false + } else { + match preflight_checks::preflight_checks() { + Ok(()) => match stick::recompute_stick_offset(&config) { + Ok(stick_offset) => match FlowActor::start() { + Ok(()) => { + let kernel_result = if config.on_a_stick_mode() { + LibreQoSKernels::on_a_stick_mode( + config.internet_interface(), + config.stick_vlans().1 as u16, + config.stick_vlans().0 as u16, + stick_offset, + Some(heimdall_handle_events), + Some(flowbee_handle_events), + ) + } else { + LibreQoSKernels::new( + &config.internet_interface(), + &config.isp_interface(), + Some(heimdall_handle_events), + Some(flowbee_handle_events), + ) + }; + + match kernel_result { + Ok(started_kernels) => { + kernels = Some(started_kernels); + blackboard::start_blackboard(); + start_remote_commands(); + let flow_tx = setup_netflow_tracker()?; + let _ = throughput_tracker::flow_data::setup_flow_analysis(); + start_heimdall()?; + spawn_queue_structure_monitor()?; + shaped_devices_tracker::shaped_devices_watcher()?; + shaped_devices_tracker::network_json_watcher()?; + shaped_devices_tracker::shaping_inputs_watcher()?; + throughput_tracker::spawn_throughput_monitor( + flow_tx, + system_usage_tx.clone(), + bakery_sender.clone().expect("checked is_some"), + )?; + spawn_queue_monitor()?; + + if let Err(err) = + treeguard::actor::start_treeguard_actor(system_usage_tx.clone()) + { + warn!("Failed to start TreeGuard actor: {err}"); + } + + lqos_sys::bpf_garbage_collector(); + shaping_runtime::mark_active( + "LibreQoS shaping is active and the web UI is available.", + ); + true + } + Err(err) => { + record_shaping_failure( + shaping_runtime::ShapingRuntimeState::ErrorKernelAttach, + "LibreQoS started the web UI, but kernel attach failed.", + format!("{err:#}"), + ); + false + } + } + } + Err(err) => { + record_shaping_failure( + shaping_runtime::ShapingRuntimeState::Degraded, + "LibreQoS started the web UI, but shaping runtime initialization failed.", + format!("{err:#}"), + ); + false + } + }, + Err(err) => { + record_shaping_failure( + shaping_runtime::ShapingRuntimeState::ErrorConfig, + "LibreQoS started the web UI, but the shaping configuration is invalid.", + format!("{err:#}"), + ); + false + } + }, + Err(err) => { + let detail = format!("{err:#}"); + record_shaping_failure( + shaping_runtime::classify_preflight_error(&detail), + "LibreQoS started the web UI, but shaping preflight failed.", + detail, + ); + false + } + } + }; + + let _ = stormguard_ready_tx.send(if stormguard_ready { + bakery_sender.clone() + } else { + None + }); + std::thread::Builder::new() .name("Signal Handler".to_string()) .spawn(move || { @@ -292,96 +468,6 @@ fn main() -> Result<()> { } } })?; - - // Create the socket server - let server = UnixSocketServer::new().expect("Unable to spawn server"); - - // Memory Debugging - memory_debug(); - - let bakery_sender_for_async = bakery_sender.clone(); - let control_tx_for_webserver = control_tx_for_web.clone(); - let handle = std::thread::Builder::new() - .name("Async Bus/Web".to_string()) - .spawn(move || { - let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - else { - error!("Unable to start Tokio runtime. Not much is going to work"); - return; - }; - tokio_runtime.block_on(async { - // Notify bakery when the bus socket becomes available - tokio::spawn(async move { - use tokio::time::{Duration, sleep}; - // Wait up to ~5 seconds for the socket to appear - for _ in 0..100u32 { - if tokio::fs::metadata(lqos_bus::BUS_SOCKET_PATH).await.is_ok() { - break; - } - sleep(Duration::from_millis(50)).await; - } - if let Some(sender) = lqos_bakery::BAKERY_SENDER.get() { - let _ = sender.send(lqos_bakery::BakeryCommands::BusReady); - } - }); - - let probe_client = - lqos_probe::ProbeManager::spawn(lqos_probe::ProbeManagerConfig::default()); - let probe_client_for_stormguard = probe_client.clone(); - probe_provider::install_probe_client(probe_client.clone()); - - tokio::spawn(async move { - match lts2_sys::control_channel::start_control_channel(control_channel).await { - Ok(_) => info!("Insight control channel started successfully"), - Err(e) => error!("Insight control channel failed to start: {:#}", e), - } - - match lqos_stormguard::start_stormguard( - bakery_sender_for_async, - shaped_devices_tracker::full_network_map_snapshot, - probe_client_for_stormguard, - ) - .await - { - Ok(_) => info!("StormGuard started successfully"), - Err(e) => error!("StormGuard failed to start: {:#}", e), - } - }); - - let (bus_tx, bus_rx) = tokio::sync::mpsc::channel::<( - tokio::sync::oneshot::Sender, - BusRequest, - )>(100); - - // Webserver starting point - let webserver_disabled = config.disable_webserver.unwrap_or(false); - if !webserver_disabled { - let control_tx_for_webserver = control_tx_for_webserver.clone(); - let probe_client_for_webserver = probe_client.clone(); - tokio::spawn(async move { - if let Err(e) = node_manager::spawn_webserver( - bus_tx, - system_usage_tx, - control_tx_for_webserver, - probe_client_for_webserver, - ) - .await - { - error!("Node Manager Failed: {e:?}"); - } - }); - } else { - warn!("Webserver disabled by configuration"); - } - - // Main bus listen loop - if let Err(e) = server.listen(handle_bus_requests, bus_rx).await { - error!("Bus stopped: {e:?}"); - } - }); - })?; let _ = handle.join(); warn!("Main thread exiting"); Ok(()) @@ -1068,12 +1154,8 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec .collect(); BusResponse::SearchResults(results) } - BusRequest::CheckInsight => { - let (status, _) = get_lts_license_status(); - match status { - LtsStatus::Invalid | LtsStatus::NotChecked => BusResponse::InsightStatus(false), - _ => BusResponse::InsightStatus(true) - } + BusRequest::GetLtsCapabilities => { + BusResponse::LtsCapabilitiesSummary(crate::lts2_sys::current_capabilities()) } BusRequest::GetInsightLicenseSummary => { let (licensed, max_circuits) = diff --git a/src/rust/lqosd/src/node_manager/js_build/src/chatbot.js b/src/rust/lqosd/src/node_manager/js_build/src/chatbot.js index c02428f4..93622474 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/chatbot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/chatbot.js @@ -4,12 +4,15 @@ import { get_ws_client } from "./pubsub/ws"; const log = document.getElementById('chatLog'); const input = document.getElementById('chatInput'); const sendBtn = document.getElementById('sendBtn'); -const insightEnabled = typeof window.hasInsight !== 'undefined' ? !!window.hasInsight : false; +const chatbotEnabled = typeof window.hasChatbot !== 'undefined' ? !!window.hasChatbot : false; +const liveControlAvailable = typeof window.liveControlAvailable !== 'undefined' + ? !!window.liveControlAvailable + : false; function scrollToBottom() { log.scrollTop = log.scrollHeight; } function ensureInsightNotice() { - if (insightEnabled) return; + if (chatbotEnabled && liveControlAvailable) return; const chatWrap = document.querySelector('.chat-wrap'); const chatLog = document.getElementById('chatLog'); if (!chatWrap || !chatLog) return; @@ -23,8 +26,12 @@ function ensureInsightNotice() {
-
Libby is an Insight feature.
- Start an Insight free trial to enable Libby on this node. +
${ + chatbotEnabled + ? "License valid, control service unavailable." + : "Libby requires an entitled license." + }
+ Open License & Services to review availability for this node.
`; chatWrap.insertBefore(notice, chatLog); @@ -37,14 +44,18 @@ function ensureInsightNotice() { ensureInsightNotice(); function disableChatWhenUnavailable() { - if (insightEnabled) return; + if (chatbotEnabled && liveControlAvailable) return; if (input) { input.disabled = true; - input.placeholder = 'Insight required to chat with Libby'; + input.placeholder = chatbotEnabled + ? 'license valid, control service unavailable' + : 'An entitled license is required to chat with Libby'; } if (sendBtn) { sendBtn.disabled = true; - sendBtn.title = 'Enable Insight to chat with Libby'; + sendBtn.title = chatbotEnabled + ? 'license valid, control service unavailable' + : 'Enable an entitled license to chat with Libby'; } } @@ -227,7 +238,7 @@ if (insightEnabled) { appendSys("Connected to Libby"); client.send({ Private: { Chatbot: { browser_ts_ms: Date.now() } } }); } else { - appendSys("Libby requires an active Insight subscription. Start a free trial to enable chat."); + appendSys("Libby requires an entitled license. Open License & Services to enable chat."); } function sendText() { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/components/timescale.js b/src/rust/lqosd/src/node_manager/js_build/src/components/timescale.js index b5dc0aa2..3ab481f8 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/components/timescale.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/components/timescale.js @@ -20,7 +20,10 @@ class TimeControls { button.classList.add("btn", "btn-sm", "me-1"); button.onclick = () => { if (period !== "Live" && !window.hasLts) { - createAndShowModal('Extended Time Periods Require Insight', 'Displaying extended time periods requires an Insight subscription or free trial. Click the "Insight" button in the menu to learn more. Invest in your network --- sign up for Insight today!'); + createAndShowModal( + "Extended History Requires an Entitled License", + 'Displaying extended time periods requires an entitled license with history access. Open "License & Services" in the menu to review availability for this node.', + ); return; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 2aa44972..a74d084e 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -3,7 +3,7 @@ import { get_ws_client } from "../pubsub/ws"; const wsClient = get_ws_client(); const secretBindings = []; -function sendWsRequest(responseEvent, request, onComplete, onError) { +export function sendWsRequest(responseEvent, request, onComplete, onError) { let done = false; const responseHandler = (msg) => { if (done) return; @@ -632,11 +632,11 @@ export function renderConfigMenu(currentPage) { { href: "config_general.html", icon: "fa-server", text: "General", id: "general" }, { href: "config_rtt.html", icon: "fa-stopwatch", text: "RTT Thresholds", id: "rtt" }, { href: "config_tuning.html", icon: "fa-warning", text: "Tuning", id: "tuning" }, - { href: "config_interface.html", icon: "fa-chain", text: "Network Mode", id: "interface" }, + { href: "config_interface.html", icon: "fa-chain", text: "Bridge & Interface Mode", id: "interface" }, { href: "config_queues.html", icon: "fa-car", text: "Queues", id: "queues" }, { href: "config_stormguard.html", icon: "fa-bolt", text: "StormGuard", id: "stormguard" }, { href: "config_treeguard.html", icon: "fa-shield-halved", text: "TreeGuard", id: "treeguard" }, - { href: "config_lts.html", icon: "fa-line-chart", text: "LibreQoS Insight", id: "lts" }, + { href: "config_lts.html", icon: "fa-line-chart", text: "License & Services", id: "lts" }, { href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" }, { href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" }, { href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js b/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js index ad69d7a9..9ac5c2b7 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js @@ -1,21 +1,92 @@ -import {saveConfig, loadConfig, renderConfigMenu} from "./config/config_helper"; +import {loadConfig, renderConfigMenu} from "./config/config_helper"; + +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; +let currentHelperStatus = null; +let currentInspection = null; +let currentPendingOperation = null; +let reconnectPollTimer = null; +let reconnectProbeInFlight = false; +let recoveryCountdownTimer = null; +let recoveryReachable = true; + +function selectedInterfaceValuesFromConfig(config) { + return { + toInternet: config?.bridge?.to_internet ?? "", + toNetwork: config?.bridge?.to_network ?? "", + singleInterface: config?.single_interface?.interface ?? "", + }; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function cloneConfig(config) { + return JSON.parse(JSON.stringify(config || {})); +} + +function configModeKind(config) { + if (config?.bridge?.use_xdp_bridge) return "xdp"; + if (config?.bridge) return "bridge"; + if (config?.single_interface) return "single"; + return "unknown"; +} + +function getJson(url) { + return fetch(url, { + headers: { "Accept": "application/json" }, + credentials: "same-origin", + }).then(async (response) => { + if (!response.ok) { + const body = await response.text(); + throw new Error(body || `Request failed: ${response.status}`); + } + return response.json(); + }); +} + +function postJson(url, body) { + return fetch(url, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify(body), + }).then(async (response) => { + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.message || `Request failed: ${response.status}`); + } + return data; + }); +} function validateConfig() { - if (document.getElementById('bridgeMode').checked) { - // Validate bridge mode fields - const toInternet = document.getElementById('toInternet').value.trim(); - const toNetwork = document.getElementById('toNetwork').value.trim(); - + if (document.getElementById("bridgeMode").checked) { + const toInternet = document.getElementById("toInternet").value.trim(); + const toNetwork = document.getElementById("toNetwork").value.trim(); if (!toInternet || !toNetwork) { alert("Both interface names are required in bridge mode"); return false; } - } else if (document.getElementById('singleInterfaceMode').checked) { - // Validate single interface mode fields - const interfaceName = document.getElementById('interface').value.trim(); - const internetVlan = parseInt(document.getElementById('internetVlan').value); - const networkVlan = parseInt(document.getElementById('networkVlan').value); - + if (toInternet === toNetwork) { + alert("Bridge mode requires two different interfaces"); + return false; + } + } else if (document.getElementById("singleInterfaceMode").checked) { + const interfaceName = document.getElementById("interface").value.trim(); + const internetVlan = parseInt(document.getElementById("internetVlan").value, 10); + const networkVlan = parseInt(document.getElementById("networkVlan").value, 10); if (!interfaceName) { alert("Interface name is required in single interface mode"); return false; @@ -35,70 +106,773 @@ function validateConfig() { return true; } -function updateConfig() { - // Clear both sections first - window.config.bridge = null; - window.config.single_interface = null; +function buildCandidateConfig() { + const next = cloneConfig(window.config); + next.bridge = null; + next.single_interface = null; - if (document.getElementById('bridgeMode').checked) { - // Update bridge configuration - window.config.bridge = { - use_xdp_bridge: document.getElementById('useXdpBridge').checked, - to_internet: document.getElementById('toInternet').value.trim(), - to_network: document.getElementById('toNetwork').value.trim() + if (document.getElementById("bridgeMode").checked) { + next.bridge = { + use_xdp_bridge: document.getElementById("useXdpBridge").checked, + to_internet: document.getElementById("toInternet").value.trim(), + to_network: document.getElementById("toNetwork").value.trim(), }; } else { - // Update single interface configuration - window.config.single_interface = { - interface: document.getElementById('interface').value.trim(), - internet_vlan: parseInt(document.getElementById('internetVlan').value), - network_vlan: parseInt(document.getElementById('networkVlan').value) + 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), }; } + + return next; +} + +function interfaceCandidates() { + return Array.isArray(currentInspection?.interface_candidates) ? currentInspection.interface_candidates : []; +} + +function optionLabel(candidate, selectedValue) { + if (!candidate) return selectedValue; + if (candidate.bridge_eligible || candidate.single_interface_eligible) { + return candidate.name; + } + if (candidate.current_selection || candidate.name === selectedValue) { + return `${candidate.name} (current selection; unavailable)`; + } + return `${candidate.name} (unavailable)`; +} + +function buildSelectOptions(selectElement, modeKey, selectedValue, excludedValue = null) { + if (!selectElement) return; + const eligibilityField = modeKey === "single" ? "single_interface_eligible" : "bridge_eligible"; + const candidates = interfaceCandidates(); + const options = [``]; + const seen = new Set(); + + candidates.forEach((candidate) => { + const selected = candidate.name === selectedValue; + const eligible = Boolean(candidate[eligibilityField]); + if (!eligible && !selected) return; + if (excludedValue && candidate.name === excludedValue && !selected) return; + options.push( + `` + ); + seen.add(candidate.name); + }); + + if (selectedValue && !seen.has(selectedValue)) { + options.push( + `` + ); + } + + selectElement.innerHTML = options.join(""); + selectElement.value = selectedValue || ""; +} + +function renderInterfaceHelp(helpElementId, modeKey, selectedValues = {}) { + const element = document.getElementById(helpElementId); + if (!element) return; + + const eligibilityField = modeKey === "single" ? "single_interface_eligible" : "bridge_eligible"; + const selectedSet = new Set( + Object.values(selectedValues) + .map((value) => String(value || "").trim()) + .filter(Boolean) + ); + const unavailable = interfaceCandidates().filter((candidate) => { + if (candidate[eligibilityField]) return false; + return !selectedSet.has(candidate.name); + }); + + if (unavailable.length === 0) { + element.innerHTML = ""; + return; + } + + element.innerHTML = ` +
+ Why some interfaces are unavailable +
    + ${unavailable.map((candidate) => { + const reason = Array.isArray(candidate.details) && candidate.details.length > 0 + ? candidate.details.join(" ") + : "Unavailable for managed LibreQoS setup."; + return `
  • ${escapeHtml(candidate.name)}: ${escapeHtml(reason)}
  • `; + }).join("")} +
+
`; +} + +function setReviewTab(tabName) { + document.querySelectorAll("[data-review-tab]").forEach((button) => { + const active = button.dataset.reviewTab === tabName; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", active ? "true" : "false"); + }); + document.querySelectorAll("[data-review-pane]").forEach((pane) => { + pane.classList.toggle("d-none", pane.dataset.reviewPane !== tabName); + }); +} + +function wireReviewTabs() { + document.querySelectorAll("[data-review-tab]").forEach((button) => { + if (button.dataset.wired === "true") return; + button.dataset.wired = "true"; + button.addEventListener("click", () => { + setReviewTab(button.dataset.reviewTab || "files"); + }); + }); +} + +function renderInterfaceSelectors(config = null) { + const selected = selectedInterfaceValuesFromConfig(config || buildCandidateConfig()); + buildSelectOptions(document.getElementById("toInternet"), "bridge", selected.toInternet, selected.toNetwork); + buildSelectOptions(document.getElementById("toNetwork"), "bridge", selected.toNetwork, selected.toInternet); + buildSelectOptions(document.getElementById("interface"), "single", selected.singleInterface); + renderInterfaceHelp("bridgeInterfaceHelp", "bridge", { + toInternet: selected.toInternet, + toNetwork: selected.toNetwork, + }); + renderInterfaceHelp("singleInterfaceHelp", "single", { + interface: selected.singleInterface, + }); +} + +function populateFormFromConfig(config) { + renderInterfaceSelectors(config); + if (config?.bridge) { + document.getElementById("bridgeMode").checked = true; + 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 ?? ""; + } 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; + } + + const event = new Event("change"); + document.querySelector('input[name="networkMode"]:checked')?.dispatchEvent(event); +} + +function loadDraft() { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (_) { + return null; + } } -// Render the configuration menu -renderConfigMenu('interface'); +function saveDraft() { + const draft = buildCandidateConfig(); + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + return draft; +} -loadConfig(() => { - // window.config now contains the configuration. - // Populate form fields with config values - if (window.config) { - // Determine which mode is active - if (window.config.bridge) { - // Bridge mode - document.getElementById('bridgeMode').checked = true; - document.getElementById('useXdpBridge').checked = - window.config.bridge.use_xdp_bridge ?? true; - document.getElementById('toInternet').value = - window.config.bridge.to_internet ?? "eth0"; - document.getElementById('toNetwork').value = - window.config.bridge.to_network ?? "eth1"; - } else if (window.config.single_interface) { - // Single interface mode - document.getElementById('singleInterfaceMode').checked = true; - document.getElementById('interface').value = - window.config.single_interface.interface ?? "eth0"; - document.getElementById('internetVlan').value = - window.config.single_interface.internet_vlan ?? 2; - document.getElementById('networkVlan').value = - window.config.single_interface.network_vlan ?? 3; +function loadPendingOperation() { + try { + const raw = localStorage.getItem(PENDING_OPERATION_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (_) { + return null; + } +} + +function pendingDeadlineMs(operation) { + if (!operation) return null; + if (Number.isFinite(operation.created_unix)) { + return (operation.created_unix * 1000) + NETPLAN_TRY_TIMEOUT_MS; + } + if (Number.isFinite(operation.saved_at_ms)) { + return operation.saved_at_ms + NETPLAN_TRY_TIMEOUT_MS; + } + return null; +} + +function formatCountdown(remainingMs) { + if (!Number.isFinite(remainingMs)) { + return "Waiting for reconnect status..."; + } + if (remainingMs <= 0) { + return "Rollback deadline reached. Waiting for LibreQoS to report the final state."; + } + const totalSeconds = Math.ceil(remainingMs / 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60); + return `Confirm or revert within ${minutes}:${String(seconds).padStart(2, "0")}.`; +} + +function setPendingOperation(operation, extra = {}) { + if (!operation?.operation_id) return; + currentPendingOperation = { + ...currentPendingOperation, + ...operation, + ...extra, + saved_at_ms: Date.now(), + }; + localStorage.setItem(PENDING_OPERATION_KEY, JSON.stringify(currentPendingOperation)); + renderRecoveryPanel(); + ensureRecoveryPolling(); +} + +function clearPendingOperation() { + currentPendingOperation = null; + localStorage.removeItem(PENDING_OPERATION_KEY); + if (reconnectPollTimer) { + clearInterval(reconnectPollTimer); + reconnectPollTimer = null; + } + if (recoveryCountdownTimer) { + clearInterval(recoveryCountdownTimer); + recoveryCountdownTimer = null; + } + renderRecoveryPanel(); +} + +function setRecoveryReachable(isReachable) { + recoveryReachable = Boolean(isReachable); + renderRecoveryPanel(); +} + +function activePendingOperationId() { + return currentHelperStatus?.pending_operation?.operation_id || currentPendingOperation?.operation_id || null; +} + +function renderRecoveryPanel() { + const panel = document.getElementById("networkRecoveryPanel"); + const summary = document.getElementById("networkRecoverySummary"); + const detail = document.getElementById("networkRecoveryDetail"); + const countdown = document.getElementById("networkRecoveryCountdown"); + const confirmButton = document.getElementById("recoveryConfirmButton"); + const revertButton = document.getElementById("recoveryRevertButton"); + if (!panel || !summary || !detail || !countdown || !confirmButton || !revertButton) return; + + const pending = currentPendingOperation; + if (!pending?.operation_id) { + panel.classList.add("d-none"); + confirmButton.disabled = true; + revertButton.disabled = true; + return; + } + + panel.classList.remove("d-none"); + panel.classList.toggle("alert-warning", !recoveryReachable); + panel.classList.toggle("alert-success", recoveryReachable); + + const deadline = pendingDeadlineMs(pending); + const remainingMs = deadline == null ? null : deadline - Date.now(); + const summaryText = pending.summary || "LibreQoS staged a network change and is waiting for confirmation."; + const reconnectText = recoveryReachable + ? "LibreQoS is reachable again. Confirm the network change if access looks correct, or revert it if not." + : "Connection is temporarily unavailable while LibreQoS tests the network change. This page will keep polling and show the confirm buttons again as soon as the box comes back."; + + summary.textContent = summaryText; + detail.textContent = `${reconnectText} Pending operation: ${pending.operation_id}`; + countdown.textContent = formatCountdown(remainingMs); + + confirmButton.disabled = !recoveryReachable; + revertButton.disabled = !recoveryReachable; +} + +function ensureRecoveryPolling() { + if (!currentPendingOperation?.operation_id) return; + if (!recoveryCountdownTimer) { + recoveryCountdownTimer = setInterval(() => { + renderRecoveryPanel(); + }, 1000); + } + if (!reconnectPollTimer) { + reconnectPollTimer = setInterval(() => { + pollRecoveryStatus(); + }, RECONNECT_POLL_INTERVAL_MS); + } +} + +function probeHealth() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + return fetch("/health", { + method: "GET", + cache: "no-store", + credentials: "same-origin", + signal: controller.signal, + }) + .then((response) => response.ok) + .catch(() => false) + .finally(() => { + clearTimeout(timeout); + }); +} + +function pollRecoveryStatus() { + if (!currentPendingOperation?.operation_id || reconnectProbeInFlight) return; + reconnectProbeInFlight = true; + probeHealth() + .then((reachable) => { + setRecoveryReachable(reachable); + if (!reachable) return null; + return refreshHelperStatus(); + }) + .catch(() => { + setRecoveryReachable(false); + }) + .finally(() => { + reconnectProbeInFlight = false; + }); +} + +function badgeClassForShapingState(state) { + switch (state) { + case "Active": + return "text-bg-success"; + case "Starting": + return "text-bg-info"; + case "Inactive": + return "text-bg-secondary"; + case "ErrorPreflight": + case "ErrorKernelAttach": + case "ErrorInterfaceMissing": + case "ErrorConfig": + return "text-bg-danger"; + case "Degraded": + return "text-bg-warning"; + default: + return "text-bg-secondary"; + } +} + +function badgeClassForInspectionState(state) { + switch (state) { + case "ManagedByLibreQoS": + return "text-bg-success"; + case "ExternalCompatible": + return "text-bg-info"; + case "Ready": + return "text-bg-primary"; + case "PendingTry": + return "text-bg-warning"; + case "Missing": + case "Conflict": + case "ComplexUnsupported": + return "text-bg-danger"; + default: + return "text-bg-secondary"; + } +} + +function renderAlertList(containerId, tone, messages) { + const container = document.getElementById(containerId); + if (!container) return; + if (!Array.isArray(messages) || messages.length === 0) { + container.innerHTML = ""; + return; + } + container.innerHTML = messages + .map((message) => `
${escapeHtml(message)}
`) + .join(""); +} + +function renderShapingStatus(status) { + const badge = document.getElementById("shapingStatusBadge"); + const summary = document.getElementById("shapingStatusSummary"); + const detail = document.getElementById("shapingStatusDetail"); + const banner = document.getElementById("shapingBanner"); + const retryButton = document.getElementById("retryShapingButton"); + if (!badge || !summary || !detail || !banner || !retryButton) return; + + const state = status?.state || "Unknown"; + badge.className = `badge ${badgeClassForShapingState(state)}`; + badge.textContent = state; + summary.textContent = status?.summary || "No shaping status available."; + + if (status?.detail) { + detail.classList.remove("d-none"); + detail.textContent = status.detail; + } else { + detail.classList.add("d-none"); + detail.textContent = ""; + } + + banner.innerHTML = status?.degraded + ? `` + : ""; + + const canRetry = Boolean(status?.can_retry); + retryButton.disabled = !canRetry; + retryButton.classList.toggle("d-none", !canRetry); +} + +function renderDetectedFiles(files) { + const container = document.getElementById("netplanFiles"); + if (!container) return; + if (!Array.isArray(files) || files.length === 0) { + container.innerHTML = `
No relevant netplan files were detected for the current selection.
`; + return; + } + + container.innerHTML = files.map((file) => { + const details = Array.isArray(file.details) && file.details.length > 0 + ? `
    ${file.details.map((detail) => `
  • ${escapeHtml(detail)}
  • `).join("")}
` + : `
No extra details.
`; + const interfaces = Array.isArray(file.relevant_interfaces) && file.relevant_interfaces.length > 0 + ? file.relevant_interfaces.map((iface) => `${escapeHtml(iface)}`).join(", ") + : "None"; + const badgeClass = file.compatible ? "text-bg-success" : badgeClassForInspectionState(file.classification); + return ` +
+
+
+
${escapeHtml(file.path)}
+
Relevant interfaces: ${interfaces}
+
+ ${escapeHtml(file.classification)} +
+ ${details} +
`; + }).join(""); +} + +function renderInspection(inspection) { + currentInspection = inspection || null; + renderInterfaceSelectors(); + const badge = document.getElementById("netplanStateBadge"); + const summary = document.getElementById("netplanSummary"); + const preview = document.getElementById("managedPreview"); + const previewMeta = document.getElementById("managedPreviewMeta"); + const previewNote = document.getElementById("managedPreviewNote"); + const diffPreview = document.getElementById("diffPreview"); + const diffPreviewMeta = document.getElementById("diffPreviewMeta"); + const applyButton = document.getElementById("applyButton"); + const adoptButton = document.getElementById("adoptButton"); + const takeoverButton = document.getElementById("takeoverButton"); + if (!badge || !summary || !preview || !previewMeta || !previewNote || !diffPreview || !diffPreviewMeta || !applyButton || !adoptButton || !takeoverButton) return; + + const state = inspection?.inspector_state || "Unknown"; + badge.className = `badge ${badgeClassForInspectionState(state)}`; + badge.textContent = state; + summary.textContent = inspection?.summary || "No netplan inspection data available."; + + const warnings = Array.isArray(inspection?.warnings) ? [...inspection.warnings] : []; + const dangerousChanges = Array.isArray(inspection?.dangerous_changes) ? inspection.dangerous_changes : []; + if (dangerousChanges.length > 0) { + warnings.unshift(...dangerousChanges); + } + renderAlertList("netplanWarnings", dangerousChanges.length > 0 ? "warning" : "warning", warnings); + renderAlertList("netplanConflicts", "danger", inspection?.conflicts || []); + renderDetectedFiles(inspection?.detected_files || []); + + previewMeta.innerHTML = ` +
Mode: ${escapeHtml(inspection?.mode_label || "Unknown")}
+
Managed file: ${escapeHtml(inspection?.managed_file_path || "/etc/netplan/libreqos.yaml")}
`; + previewNote.innerHTML = [ + inspection?.strong_confirmation_text + ? `
${escapeHtml(inspection.strong_confirmation_text)}
` + : "", + inspection?.preview_note + ? `
${escapeHtml(inspection.preview_note)}
` + : "", + ].join(""); + preview.textContent = inspection?.managed_preview_yaml || ""; + diffPreviewMeta.innerHTML = inspection?.diff_preview_label + ? `
Diff: ${escapeHtml(inspection.diff_preview_label)}
` + : `
No diff preview available.
`; + diffPreview.textContent = inspection?.diff_preview || "No diff preview available."; + + [ + "bridgeMode", + "singleInterfaceMode", + "useXdpBridge", + "toInternet", + "toNetwork", + "interface", + "internetVlan", + "networkVlan", + "saveButton", + ].forEach((id) => { + const element = document.getElementById(id); + if (element) { + element.disabled = Boolean(inspection?.editing_locked); } - - // Trigger form visibility update - const event = new Event('change'); - document.querySelector('input[name="networkMode"]:checked')?.dispatchEvent(event); + }); + applyButton.disabled = !inspection?.can_apply; + adoptButton.disabled = !inspection?.can_adopt; + takeoverButton.disabled = !inspection?.can_take_over; +} - // Add save button click handler - document.getElementById('saveButton').addEventListener('click', () => { - if (validateConfig()) { - updateConfig(); - saveConfig(() => { - alert("Configuration saved successfully!"); - }); +function renderHelperStatus(status) { + currentHelperStatus = status || null; + const pendingEl = document.getElementById("pendingChange"); + const lastBackupEl = document.getElementById("lastBackup"); + const confirmButton = document.getElementById("confirmPendingButton"); + const revertButton = document.getElementById("revertPendingButton"); + const restoreButton = document.getElementById("restoreLatestBackupButton"); + if (!pendingEl || !lastBackupEl || !confirmButton || !revertButton || !restoreButton) return; + + const pending = status?.pending_operation || null; + if (pending) { + setPendingOperation(pending); + pendingEl.innerHTML = ` +
${escapeHtml(pending.state)}
+
${escapeHtml(pending.summary)}
+
${escapeHtml(pending.operation_id)}
`; + confirmButton.disabled = false; + revertButton.disabled = false; + } else { + clearPendingOperation(); + pendingEl.innerHTML = `
No pending network change.
`; + confirmButton.disabled = true; + revertButton.disabled = true; + } + + const recentBackups = Array.isArray(status?.recent_backups) ? status.recent_backups : []; + if (recentBackups.length > 0) { + const recent = recentBackups.map((backup) => { + const warnings = Array.isArray(backup.warnings_present) && backup.warnings_present.length > 0 + ? `
${backup.warnings_present.map(escapeHtml).join(" | ")}
` + : ""; + return ` +
+
${escapeHtml(backup.backup_id)}
+
${escapeHtml(backup.old_mode)} -> ${escapeHtml(backup.new_mode)}
+ ${warnings} +
`; + }).join(""); + lastBackupEl.innerHTML = recent; + restoreButton.disabled = Boolean(pending); + } else { + lastBackupEl.innerHTML = `
No helper backup recorded yet.
`; + restoreButton.disabled = true; + } +} + +function refreshHelperStatus() { + return getJson("/local-api/network-mode/status") + .then((data) => { + renderHelperStatus(data?.helper_status || {}); + setRecoveryReachable(true); + return data?.helper_status || {}; + }) + .catch(() => { + if (!currentPendingOperation?.operation_id) { + renderHelperStatus(null); + } + setRecoveryReachable(false); + throw new Error("Unable to refresh helper status"); + }); +} + +function inspectCandidate() { + if (!validateConfig()) return Promise.resolve(); + const candidate = buildCandidateConfig(); + const button = document.getElementById("inspectButton"); + if (button) { + button.disabled = true; + button.textContent = "Inspecting..."; + } + + return postJson("/local-api/network-mode/inspect", { config: candidate }) + .then((inspection) => { + renderInspection(inspection); + return refreshHelperStatus(); + }) + .catch((err) => { + alert(err.message || "Unable to inspect current netplan"); + }) + .finally(() => { + if (button) { + button.disabled = false; + button.textContent = "Inspect Current Netplan"; } }); - } else { - console.error("Configuration not found in window.config"); +} + +function confirmDangerousChange(actionLabel, candidate) { + const warnings = Array.isArray(currentInspection?.dangerous_changes) ? currentInspection.dangerous_changes.slice() : []; + if (configModeKind(window.config) !== configModeKind(candidate)) { + warnings.push("Switching between Linux bridge and single-interface modes requires strong confirmation."); } + if (warnings.length === 0 && actionLabel === "Apply") { + return true; + } + + const intro = currentInspection?.strong_confirmation_text + || "This change may interrupt access to this system. LibreQoS will automatically roll back if you do not confirm within 30 seconds. If your browser disconnects, stay on this page and it will keep trying to reconnect."; + return window.confirm(`${actionLabel} requires confirmation:\n\n- ${warnings.join("\n- ")}\n\n${intro}`); +} + +function applyNetworkChanges(mode = "Apply") { + if (!validateConfig()) return; + const candidate = buildCandidateConfig(); + const actionLabel = mode === "Adopt" ? "Adopt into libreqos.yaml" : mode === "TakeOver" ? "Take Over libreqos.yaml" : "Apply Network Changes"; + if (!confirmDangerousChange(actionLabel, candidate)) { + return; + } + const button = document.getElementById("applyButton"); + if (button) { + button.disabled = true; + button.textContent = "Applying..."; + } + + postJson("/local-api/network-mode/apply", { + config: candidate, + mode, + confirm_dangerous_changes: true, + }) + .then((response) => { + if (response?.operation) { + setPendingOperation(response.operation, { + action_label: actionLabel, + }); + } + localStorage.removeItem(DRAFT_KEY); + window.config = candidate; + renderRecoveryPanel(); + window.scrollTo({ top: 0, behavior: "smooth" }); + return Promise.allSettled([inspectCandidate(), refreshHelperStatus()]); + }) + .catch((err) => { + alert(err.message || "Unable to apply network changes"); + }) + .finally(() => { + if (button) { + button.disabled = false; + button.textContent = "Apply Network Changes"; + } + }); +} + +function actOnPending(endpoint) { + const operationId = activePendingOperationId(); + if (!operationId) { + alert("No pending network operation is available"); + return; + } + + postJson(endpoint, { operation_id: operationId }) + .then((response) => { + clearPendingOperation(); + alert(response?.message || "Operation completed"); + return Promise.allSettled([refreshHelperStatus(), inspectCandidate()]); + }) + .catch((err) => { + alert(err.message || "Operation failed"); + }); +} + +function rollbackLatestBackup() { + const pending = currentHelperStatus?.pending_operation; + if (pending?.operation_id) { + alert("Confirm or revert the pending network change before restoring an older backup."); + return; + } + + const backupId = currentHelperStatus?.recent_backups?.[0]?.backup_id; + if (!backupId) { + alert("No rollback bundle is available."); + return; + } + + const confirmed = window.confirm( + "Restore the previous managed LibreQoS network configuration?\n\n" + + "This will overwrite the current managed netplan state with the most recent backup and may interrupt access to this system." + ); + if (!confirmed) { + return; + } + + postJson("/local-api/network-mode/rollback", { backup_id: backupId }) + .then((response) => { + alert(response?.message || "Rollback completed"); + return Promise.all([refreshHelperStatus(), inspectCandidate()]); + }) + .catch((err) => { + alert(err.message || "Unable to restore the previous managed config"); + }); +} + +function retryShaping() { + const button = document.getElementById("retryShapingButton"); + if (button) { + button.disabled = true; + button.textContent = "Retrying..."; + } + + postJson("/local-api/network-mode/retry-shaping", {}) + .then((response) => { + alert(response?.message || "Shaping retry requested"); + return Promise.all([refreshHelperStatus(), inspectCandidate()]); + }) + .catch((err) => { + alert(err.message || "Unable to retry shaping"); + }) + .finally(() => { + if (button) { + button.disabled = false; + button.textContent = "Retry Shaping"; + } + }); +} + +function wireActions() { + wireReviewTabs(); + [ + "bridgeMode", + "singleInterfaceMode", + "toInternet", + "toNetwork", + "interface", + ].forEach((id) => { + document.getElementById(id).addEventListener("change", () => { + renderInterfaceSelectors(); + }); + }); + document.getElementById("saveButton").addEventListener("click", () => { + if (!validateConfig()) return; + saveDraft(); + inspectCandidate(); + alert("Network mode draft saved in this browser. Use Apply Network Changes to commit both lqos.conf and netplan together."); + }); + document.getElementById("inspectButton").addEventListener("click", inspectCandidate); + document.getElementById("applyButton").addEventListener("click", () => applyNetworkChanges("Apply")); + document.getElementById("adoptButton").addEventListener("click", () => applyNetworkChanges("Adopt")); + document.getElementById("takeoverButton").addEventListener("click", () => applyNetworkChanges("TakeOver")); + document.getElementById("retryShapingButton").addEventListener("click", retryShaping); + document.getElementById("confirmPendingButton").addEventListener("click", () => { + actOnPending("/local-api/network-mode/confirm"); + }); + document.getElementById("revertPendingButton").addEventListener("click", () => { + actOnPending("/local-api/network-mode/revert"); + }); + document.getElementById("recoveryConfirmButton").addEventListener("click", () => { + actOnPending("/local-api/network-mode/confirm"); + }); + document.getElementById("recoveryRevertButton").addEventListener("click", () => { + actOnPending("/local-api/network-mode/revert"); + }); + document.getElementById("restoreLatestBackupButton").addEventListener("click", rollbackLatestBackup); +} + +renderConfigMenu("interface"); + +loadConfig((msg) => { + const payload = msg?.data || {}; + window.config = payload.config || window.config || {}; + currentPendingOperation = loadPendingOperation(); + + renderShapingStatus(payload.shaping_status || {}); + renderInspection(payload.network_mode_inspection || {}); + const draft = loadDraft(); + populateFormFromConfig(draft || window.config); + renderRecoveryPanel(); + wireActions(); + refreshHelperStatus().catch(() => {}); + ensureRecoveryPolling(); +}, () => { + alert("Unable to load LibreQoS configuration"); }); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_lts.js b/src/rust/lqosd/src/node_manager/js_build/src/config_lts.js index 9178cb96..d00c0a2c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_lts.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_lts.js @@ -1,20 +1,29 @@ -import {saveConfig, loadConfig, renderConfigMenu} from "./config/config_helper"; +import { + loadConfig, + renderConfigMenu, + saveConfig, + sendWsRequest, +} from "./config/config_helper"; + +const licenseKeyInput = document.getElementById("licenseKey"); +const toggleLicenseKeyButton = document.getElementById("toggleLicenseKey"); +const clearLicenseKeyButton = document.getElementById("clearLicenseKey"); +const saveButton = document.getElementById("saveButton"); +const retryButton = document.getElementById("retryLicenseCheck"); function validateConfig() { - // Validate numeric fields - const collationPeriod = parseInt(document.getElementById("collationPeriod").value); - if (isNaN(collationPeriod) || collationPeriod < 1) { + const collationPeriod = parseInt(document.getElementById("collationPeriod").value, 10); + if (Number.isNaN(collationPeriod) || collationPeriod < 1) { alert("Collation Period must be a number greater than 0"); return false; } - const uispInterval = parseInt(document.getElementById("uispInterval").value); - if (isNaN(uispInterval) || uispInterval < 0) { + const uispInterval = parseInt(document.getElementById("uispInterval").value, 10); + if (Number.isNaN(uispInterval) || uispInterval < 0) { alert("UISP Reporting Interval must be a number of at least 0"); return false; } - // Validate URL format if provided const ltsUrl = document.getElementById("ltsUrl").value.trim(); if (ltsUrl) { try { @@ -29,46 +38,199 @@ function validateConfig() { } function updateConfig() { - // Update only the long-term stats section window.config.long_term_stats = { gather_stats: document.getElementById("gatherStats").checked, - collation_period_seconds: parseInt(document.getElementById("collationPeriod").value), - license_key: document.getElementById("licenseKey").value.trim() || null, - uisp_reporting_interval_seconds: parseInt(document.getElementById("uispInterval").value) || null, - lts_url: document.getElementById("ltsUrl").value.trim() || null + collation_period_seconds: parseInt(document.getElementById("collationPeriod").value, 10), + license_key: licenseKeyInput.value.trim() || null, + uisp_reporting_interval_seconds: parseInt(document.getElementById("uispInterval").value, 10) || null, + lts_url: document.getElementById("ltsUrl").value.trim() || null, }; } -// Render the configuration menu -renderConfigMenu('lts'); +function setLicenseKeyVisibility(revealed) { + licenseKeyInput.type = revealed ? "text" : "password"; + toggleLicenseKeyButton.innerHTML = revealed + ? '' + : ''; + toggleLicenseKeyButton.setAttribute( + "aria-label", + revealed ? "Hide license key" : "Reveal license key", + ); +} + +function formatMappedCircuitLimit(limit) { + if (limit === null || limit === undefined) { + return "Unlimited"; + } + return Number(limit).toLocaleString(); +} + +function renderCapabilityBadges(capabilities) { + const container = document.getElementById("licenseCapabilityBadges"); + if (!container) { + return; + } + + const badgeSpec = [ + { + enabled: capabilities.can_view_insight_ui, + label: "Insight UI", + }, + { + enabled: capabilities.can_use_api_link, + label: "API Docs", + }, + { + enabled: capabilities.can_use_support_tickets, + label: "Support", + }, + { + enabled: capabilities.can_use_chatbot, + label: "Libby", + }, + { + enabled: capabilities.can_receive_remote_commands, + label: "Remote Control", + }, + { + enabled: capabilities.can_submit_long_term_stats, + label: "Stats Submit", + }, + ]; + + container.innerHTML = badgeSpec + .map((badge) => { + const css = badge.enabled + ? "badge rounded-pill text-bg-success-subtle text-success-emphasis border border-success-subtle" + : "badge rounded-pill text-bg-light text-secondary border"; + return `${badge.label}`; + }) + .join(""); +} + +function renderAvailability(capabilities) { + document.getElementById("licenseStateLabel").textContent = capabilities.license_state_label || "Unknown"; + document.getElementById("licenseAuthorityBadge").textContent = capabilities.authority_label || "Unknown"; + document.getElementById("controlServiceStatus").textContent = capabilities.control_service_reachable + ? "Reachable" + : "Unavailable"; + document.getElementById("mappedCircuitLimit").textContent = formatMappedCircuitLimit( + capabilities.mapped_circuit_limit, + ); + + renderCapabilityBadges(capabilities); + + const alert = document.getElementById("licenseAvailabilityAlert"); + if (!alert) { + return; + } + + let message = ""; + if ( + !capabilities.control_service_reachable + && (capabilities.can_use_support_tickets || capabilities.can_use_chatbot) + ) { + message = "license valid, control service unavailable"; + } else if (capabilities.bootstrap_suppressed) { + message = "Automatic bootstrap retries are currently suppressed for the configured key."; + } else if ( + capabilities.bootstrap_intent + && !capabilities.control_service_reachable + && capabilities.authority_label === "Bootstrap pending" + ) { + message = "Bootstrap is pending. Save a corrected key or use Retry License Check to try again."; + } + + if (message) { + alert.textContent = message; + alert.classList.remove("d-none"); + } else { + alert.textContent = ""; + alert.classList.add("d-none"); + } +} + +function fetchCapabilities(request = { LtsCapabilities: {} }) { + sendWsRequest( + "LtsCapabilitiesResult", + request, + (msg) => { + renderAvailability(msg.data || {}); + }, + (msg) => { + const alert = document.getElementById("licenseAvailabilityAlert"); + if (!alert) { + return; + } + alert.textContent = msg?.message || "Unable to load license status."; + alert.classList.remove("d-none"); + }, + ); +} + +function wireActions() { + setLicenseKeyVisibility(false); + + toggleLicenseKeyButton.addEventListener("click", () => { + setLicenseKeyVisibility(licenseKeyInput.type === "password"); + }); + + clearLicenseKeyButton.addEventListener("click", () => { + licenseKeyInput.value = ""; + }); + + retryButton.addEventListener("click", () => { + retryButton.disabled = true; + sendWsRequest( + "LtsCapabilitiesResult", + { LtsRetryLicenseCheck: {} }, + (msg) => { + retryButton.disabled = false; + renderAvailability(msg.data || {}); + }, + (msg) => { + retryButton.disabled = false; + alert(msg?.message || "Unable to retry license check."); + }, + ); + }); + + saveButton.addEventListener("click", () => { + if (!validateConfig()) { + return; + } + + updateConfig(); + saveButton.disabled = true; + saveConfig( + () => { + saveButton.disabled = false; + fetchCapabilities(); + alert("Configuration saved successfully!"); + }, + (msg) => { + saveButton.disabled = false; + alert(msg?.message || "That didn't work"); + }, + ); + }); +} + +renderConfigMenu("lts"); loadConfig(() => { - // window.config now contains the configuration. - // Populate form fields with config values - if (window.config && window.config.long_term_stats) { - const lts = window.config.long_term_stats; - - // Boolean fields - document.getElementById("gatherStats").checked = lts.gather_stats ?? true; - - // Numeric fields - document.getElementById("collationPeriod").value = lts.collation_period_seconds ?? 60; - document.getElementById("uispInterval").value = lts.uisp_reporting_interval_seconds ?? 300; - - // Optional string fields - document.getElementById("licenseKey").value = lts.license_key ?? ""; - document.getElementById("ltsUrl").value = lts.lts_url ?? ""; - - // Add save button click handler - document.getElementById('saveButton').addEventListener('click', () => { - if (validateConfig()) { - updateConfig(); - saveConfig(() => { - alert("Configuration saved successfully!"); - }); - } - }); - } else { + if (!window.config || !window.config.long_term_stats) { console.error("Long-term stats configuration not found in window.config"); + return; } + + const lts = window.config.long_term_stats; + document.getElementById("gatherStats").checked = lts.gather_stats ?? true; + document.getElementById("collationPeriod").value = lts.collation_period_seconds ?? 60; + document.getElementById("uispInterval").value = lts.uisp_reporting_interval_seconds ?? 300; + document.getElementById("ltsUrl").value = lts.lts_url ?? ""; + licenseKeyInput.value = lts.license_key ?? ""; + + wireActions(); + fetchCapabilities(); }); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js b/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js index 6d9756e8..d50a6dc2 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js @@ -323,7 +323,7 @@ function renderOverview() { if (!window.hasInsight) { const promo = document.createElement("div"); promo.className = "cpu-affinity-inline-note mb-3"; - promo.innerHTML = 'Enable Insight to improve CPU planning with historical data.'; + promo.innerHTML = 'Enable historical services in License & Services to improve CPU planning.'; body.appendChild(promo); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js index fe94faad..e7bce060 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js @@ -102,7 +102,7 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet {
-
Insight
+
Control Service
${badge}
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js index e0d68c12..886282b0 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js @@ -96,7 +96,7 @@ export class QueueStatsTotalDash extends DashletBaseInsight { } if (!window.hasLts) { - historyGraph.innerHTML = "

You need an active LibreQoS Insight account to view this data.

"; + historyGraph.innerHTML = "

You need an entitled license with history access to view this data.

"; return; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_bps_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_bps_dash.js index fb3a34d0..a87e900a 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_bps_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_bps_dash.js @@ -134,7 +134,7 @@ export class ThroughputBpsDash extends DashletBaseInsight{ const yestValue = document.createElement("span"); yestValue.classList.add("fw-bold", "text-secondary"); - yestValue.innerHTML = " History
Requires
Insight"; + yestValue.innerHTML = " History
Requires
Entitlement"; yestWrapper.appendChild(yestHeader); yestWrapper.appendChild(yestValue); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/total_retransmits.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/total_retransmits.js index 7c76e897..f107ed43 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/total_retransmits.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/total_retransmits.js @@ -41,7 +41,7 @@ export class TcpRetransmitsDash extends DashletBaseInsight{ graph.id = this.graphDivId() + "_" + t; graph.classList.add("dashgraph"); graph.style.display = "none"; - graph.innerHTML = window.hasLts ? "Loading..." : "

You need an active LibreQoS Insight account to view this data.

"; + graph.innerHTML = window.hasLts ? "Loading..." : "

You need an entitled license with history access to view this data.

"; this.graphDivs.push(graph); controls.appendChild(this.makePeriodBtn(t)); }); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/help.js b/src/rust/lqosd/src/node_manager/js_build/src/help.js index 482fd71a..c1853865 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/help.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/help.js @@ -222,11 +222,14 @@ function submitComment() { } function initSupportTickets() { - const hasInsight = typeof window.hasInsight !== "undefined" ? !!window.hasInsight : false; const hasSupportTickets = typeof window.hasSupportTickets !== "undefined" ? !!window.hasSupportTickets - : hasInsight; + : false; + const liveControlAvailable = + typeof window.liveControlAvailable !== "undefined" + ? !!window.liveControlAvailable + : false; const noInsight = document.getElementById("ticketGateNoInsight"); const yesInsight = document.getElementById("ticketGateInsight"); const btnNew = document.getElementById("btnNewTicket"); @@ -243,8 +246,12 @@ function initSupportTickets() { if (noInsight) noInsight.classList.add("d-none"); if (yesInsight) yesInsight.classList.remove("d-none"); - if (btnNew) btnNew.disabled = false; - if (btnRefresh) btnRefresh.disabled = false; + if (btnNew) btnNew.disabled = !liveControlAvailable; + if (btnRefresh) btnRefresh.disabled = !liveControlAvailable; + + if (!liveControlAvailable) { + setAlert("ticketListAlert", "license valid, control service unavailable"); + } wsClient = get_ws_client(); wsClient.on("SupportTicketListResult", (msg) => { @@ -278,8 +285,8 @@ function initSupportTickets() { wsClient.on("Error", (msg) => { const message = msg && msg.message ? String(msg.message) : "Unknown error"; if ( - message.includes("Support tickets require an Insight subscription") || - message.includes("Insight not enabled") + message.includes("Support tickets require an entitled license.") || + message.includes("license valid, control service unavailable") ) { if (noInsight) noInsight.classList.remove("d-none"); if (yesInsight) yesInsight.classList.add("d-none"); diff --git a/src/rust/lqosd/src/node_manager/local_api.rs b/src/rust/lqosd/src/node_manager/local_api.rs index bb5ea6b6..3f6f5418 100644 --- a/src/rust/lqosd/src/node_manager/local_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api.rs @@ -13,6 +13,7 @@ pub(crate) mod executive_cache; pub(crate) mod flow_explorer; pub(crate) mod flow_map; pub mod lts; +pub(crate) mod network_mode; pub(crate) mod network_tree; pub(crate) mod network_tree_lite; pub(crate) mod node_rate_overrides; @@ -33,7 +34,7 @@ pub(crate) mod warnings; use crate::node_manager::auth::auth_layer; use crate::node_manager::shaper_queries_actor::ShaperQueryCommand; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Extension, Router}; use tower_http::cors::CorsLayer; @@ -44,6 +45,17 @@ pub fn local_api(shaper_query: tokio::sync::mpsc::Sender) -> "/throughputAttributionDebug", get(throughput_attribution_debug::throughput_attribution_debug), ) + .route("/network-mode/status", get(network_mode::status)) + .route("/network-mode/inspect", post(network_mode::inspect)) + .route("/network-mode/apply", post(network_mode::apply)) + .route("/network-mode/confirm", post(network_mode::confirm)) + .route("/network-mode/revert", post(network_mode::revert)) + .route("/network-mode/rollback", post(network_mode::rollback)) + .route( + "/network-mode/retry-shaping", + post(network_mode::retry_shaping), + ) + .with_state(network_mode::NetworkModeApiState::default()) .layer(Extension(shaper_query)) .layer(CorsLayer::very_permissive()) .route_layer(axum::middleware::from_fn(auth_layer)) diff --git a/src/rust/lqosd/src/node_manager/local_api/config.rs b/src/rust/lqosd/src/node_manager/local_api/config.rs index ba7d9b64..3d9f23eb 100644 --- a/src/rust/lqosd/src/node_manager/local_api/config.rs +++ b/src/rust/lqosd/src/node_manager/local_api/config.rs @@ -1,5 +1,7 @@ use crate::node_manager::auth::LoginResult; +use crate::node_manager::local_api::network_mode::NetworkModeInspection; use crate::shaped_devices_tracker::SHAPED_DEVICES; +use crate::shaping_runtime::ShapingRuntimeStatus; use axum::http::StatusCode; use default_net::get_interfaces; use lqos_bus::{BusRequest, bus_request}; @@ -156,6 +158,10 @@ pub struct ConfigView { pub config: Config, #[serde(default)] pub secret_state: ConfigSecretState, + #[serde(default)] + pub shaping_status: ShapingRuntimeStatus, + #[serde(default)] + pub network_mode_inspection: NetworkModeInspection, } pub fn admin_check_data(login: LoginResult) -> bool { @@ -317,6 +323,9 @@ pub fn get_config_data(login: LoginResult) -> Result { let mut config = (*config).clone(); let secret_state = redact_config_secrets(&mut config); ConfigView { + shaping_status: crate::shaping_runtime::get_status(), + network_mode_inspection: + crate::node_manager::local_api::network_mode::inspect_network_mode(&config), config, secret_state, } diff --git a/src/rust/lqosd/src/node_manager/local_api/lts.rs b/src/rust/lqosd/src/node_manager/local_api/lts.rs index e7214286..baca3ceb 100644 --- a/src/rust/lqosd/src/node_manager/local_api/lts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/lts.rs @@ -2,11 +2,11 @@ mod last_24_hours; mod shaper_status; use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status}; -use crate::lts2_sys::shared_types::LtsStatus; use crate::node_manager::auth::LoginResult; use crate::node_manager::local_api::circuit_count; use axum::http::StatusCode; pub use last_24_hours::*; +use lqos_bus::LtsCapabilitiesSummary; use lqos_bus::{BusRequest, bus_request}; use lqos_config::load_config; use serde::{Deserialize, Serialize}; @@ -135,6 +135,9 @@ async fn apply_insight_license( bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(cfg))]) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + crate::lts2_sys::capabilities::set_signup_bootstrap_active(false); + crate::lts2_sys::capabilities::clear_bootstrap_suppression(); + crate::lts2_sys::capabilities::wake_control_channel(); set_license_status(LicenseStatus { license_type: INSIGHT_FREE_TRIAL_STATUS_CODE, @@ -153,6 +156,7 @@ async fn apply_insight_license( } pub async fn lts_trial_start_signup_data() -> Result { + crate::lts2_sys::capabilities::set_signup_bootstrap_active(true); let config = load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let start_url = build_insight_url(config.long_term_stats.lts_url.as_deref(), "/su/start"); let request = SignupStartRequest { @@ -253,6 +257,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> Ok(SignupCheckState::Pending) => {} Ok(SignupCheckState::Expired) => { debug!(claim_id = %claim_id, "Insight signup claim expired"); + crate::lts2_sys::capabilities::set_signup_bootstrap_active(false); return Ok(()); } Ok(SignupCheckState::Provisioned { account_key }) => { @@ -263,6 +268,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> account_key = %account_key, "Applied Insight license from signup flow" ); + crate::lts2_sys::capabilities::set_signup_bootstrap_active(false); return Ok(()); } Err(status) => { @@ -272,6 +278,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> status = %status, "Failed to apply Insight license; stopping signup poll loop" ); + crate::lts2_sys::capabilities::set_signup_bootstrap_active(false); return Err(status); } } @@ -281,6 +288,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> claim_id = %claim_id, "Insight signup status poll returned an unexpected payload" ); + crate::lts2_sys::capabilities::set_signup_bootstrap_active(false); return Err(StatusCode::BAD_GATEWAY); } } @@ -288,33 +296,51 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> } pub(crate) async fn insight_gate() -> Result<(), StatusCode> { - let (status, _) = crate::lts2_sys::get_lts_license_status_async().await; - match status { - LtsStatus::Invalid | LtsStatus::NotChecked => Err(StatusCode::FORBIDDEN), - _ => Ok(()), + let capabilities = crate::lts2_sys::current_capabilities(); + if !capabilities.can_view_insight_ui { + Err(StatusCode::FORBIDDEN) + } else if !capabilities.control_service_reachable { + Err(StatusCode::SERVICE_UNAVAILABLE) + } else { + Ok(()) } } pub(crate) async fn support_ticket_gate() -> Result<(), StatusCode> { - let (status, _) = crate::lts2_sys::get_lts_license_status_async().await; - match status { - LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full => { - Ok(()) - } - _ => Err(StatusCode::FORBIDDEN), + let capabilities = crate::lts2_sys::current_capabilities(); + if !capabilities.can_use_support_tickets { + Err(StatusCode::FORBIDDEN) + } else if !capabilities.control_service_reachable { + Err(StatusCode::SERVICE_UNAVAILABLE) + } else { + Ok(()) } } pub async fn lts_trial_signup_data(license_key: String) -> Result<(), StatusCode> { - info!("Received license key, enabling free trial: {}", license_key); if license_key == "FAIL" { warn!("Free trial request failed"); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - info!("Free trial request succeeded, license key: {}", license_key); apply_insight_license(license_key, true).await } +pub fn lts_capabilities_data(login: LoginResult) -> Result { + if login != LoginResult::Admin { + return Err(StatusCode::FORBIDDEN); + } + Ok(crate::lts2_sys::current_capabilities()) +} + +pub fn retry_license_check_data(login: LoginResult) -> Result { + if login != LoginResult::Admin { + return Err(StatusCode::FORBIDDEN); + } + crate::lts2_sys::capabilities::clear_bootstrap_suppression(); + crate::lts2_sys::capabilities::wake_control_channel(); + Ok(crate::lts2_sys::current_capabilities()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/src/rust/lqosd/src/node_manager/local_api/network_mode.rs b/src/rust/lqosd/src/node_manager/local_api/network_mode.rs new file mode 100644 index 00000000..fbda8395 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/network_mode.rs @@ -0,0 +1,319 @@ +use crate::node_manager::auth::{LoginResult, get_username}; +use axum::Json; +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use axum_extra::extract::CookieJar; +use lqos_config::Config; +use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest, ApplyResponse, HelperStatus}; +use lqos_netplan_helper::transaction::{ + HelperPaths, PendingChildren, apply_transaction, confirm_transaction, helper_status, + inspect_with_paths, retry_shaping_transaction, revert_transaction, rollback_transaction, +}; +pub use lqos_netplan_helper::{NetworkModeInspection, inspect_network_mode}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{error, warn}; + +#[derive(Clone, Debug)] +pub struct NetworkModeApiState { + paths: Arc, + pending_children: Arc>, +} + +impl Default for NetworkModeApiState { + fn default() -> Self { + let paths = Arc::new(HelperPaths::default()); + let mut pending_children = PendingChildren::default(); + if let Err(err) = helper_status(paths.as_ref(), &mut pending_children) { + warn!("Unable to initialize network-mode helper state: {err}"); + } + Self { + paths, + pending_children: Arc::new(Mutex::new(pending_children)), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NetworkModeInspectRequest { + pub config: Config, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NetworkModeApplyRequest { + pub config: Config, + #[serde(default)] + pub mode: ApplyMode, + #[serde(default)] + pub confirm_dangerous_changes: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NetworkModeConfirmRequest { + pub operation_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NetworkModeRollbackRequest { + pub backup_id: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct NetworkModeStateResponse { + pub helper_status: HelperStatus, +} + +fn unauthorized() -> (StatusCode, Json) { + ( + StatusCode::FORBIDDEN, + Json(ApplyResponse { + ok: false, + message: "Unauthorized".to_string(), + operation: None, + last_backup_id: None, + }), + ) +} + +fn helper_validation_error_response(message: String) -> (StatusCode, Json) { + warn!("Bridge helper request failed: {message}"); + ( + StatusCode::BAD_REQUEST, + Json(ApplyResponse { + ok: false, + message, + operation: None, + last_backup_id: None, + }), + ) +} + +fn helper_internal_error_response(message: String) -> (StatusCode, Json) { + error!("Bridge helper internal error: {message}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApplyResponse { + ok: false, + message, + operation: None, + last_backup_id: None, + }), + ) +} + +fn merge_network_mode(candidate: Config) -> Result { + let live = lqos_config::load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut merged = (*live).clone(); + merged.bridge = candidate.bridge; + merged.single_interface = candidate.single_interface; + Ok(merged) +} + +async fn run_network_mode_inspection( + state: &NetworkModeApiState, + config: Config, +) -> Result { + let paths = state.paths.clone(); + tokio::task::spawn_blocking(move || inspect_with_paths(paths.as_ref(), &config)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn run_pending_operation( + state: &NetworkModeApiState, + operation: F, +) -> Result +where + T: Send + 'static, + F: FnOnce(&HelperPaths, &mut PendingChildren) -> anyhow::Result + Send + 'static, +{ + let paths = state.paths.clone(); + let pending_children = state.pending_children.clone(); + tokio::task::spawn_blocking(move || { + let mut pending = pending_children.blocking_lock(); + operation(paths.as_ref(), &mut pending) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +pub async fn status( + State(state): State, + Extension(login): Extension, +) -> Result, StatusCode> { + if login != LoginResult::Admin { + return Err(StatusCode::FORBIDDEN); + } + + let helper_status = run_pending_operation(&state, helper_status).await?; + + Ok(Json(NetworkModeStateResponse { helper_status })) +} + +pub async fn inspect( + State(state): State, + Extension(login): Extension, + Json(body): Json, +) -> Result, StatusCode> { + if login != LoginResult::Admin { + return Err(StatusCode::FORBIDDEN); + } + + let merged = merge_network_mode(body.config)?; + Ok(Json(run_network_mode_inspection(&state, merged).await?)) +} + +pub async fn apply( + State(state): State, + jar: CookieJar, + Extension(login): Extension, + Json(body): Json, +) -> (StatusCode, Json) { + if login != LoginResult::Admin { + return unauthorized(); + } + + 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, + }), + ); + } + }; + let result = tokio::task::spawn_blocking({ + let paths = state.paths.clone(); + let pending_children = state.pending_children.clone(); + move || { + let mut pending = pending_children.blocking_lock(); + apply_transaction( + paths.as_ref(), + &mut pending, + ApplyRequest { + config: merged, + source: "ui".to_string(), + operator_username: Some(username), + mode: body.mode, + confirm_dangerous_changes: body.confirm_dangerous_changes, + }, + ) + } + }) + .await; + 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}")), + } +} + +pub async fn confirm( + State(state): State, + Extension(login): Extension, + Json(body): Json, +) -> (StatusCode, Json) { + if login != LoginResult::Admin { + return unauthorized(); + } + + let result = tokio::task::spawn_blocking({ + let paths = state.paths.clone(); + let pending_children = state.pending_children.clone(); + let operation_id = body.operation_id.clone(); + move || { + let mut pending = pending_children.blocking_lock(); + confirm_transaction(paths.as_ref(), &mut pending, &operation_id) + } + }) + .await; + 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}")), + } +} + +pub async fn revert( + State(state): State, + Extension(login): Extension, + Json(body): Json, +) -> (StatusCode, Json) { + if login != LoginResult::Admin { + return unauthorized(); + } + + let result = tokio::task::spawn_blocking({ + let paths = state.paths.clone(); + let pending_children = state.pending_children.clone(); + let operation_id = body.operation_id.clone(); + move || { + let mut pending = pending_children.blocking_lock(); + revert_transaction(paths.as_ref(), &mut pending, &operation_id) + } + }) + .await; + 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}")), + } +} + +pub async fn rollback( + State(state): State, + Extension(login): Extension, + Json(body): Json, +) -> (StatusCode, Json) { + if login != LoginResult::Admin { + return unauthorized(); + } + + let result = tokio::task::spawn_blocking({ + let paths = state.paths.clone(); + let pending_children = state.pending_children.clone(); + let backup_id = body.backup_id.clone(); + move || { + let mut pending = pending_children.blocking_lock(); + rollback_transaction(paths.as_ref(), &mut pending, &backup_id) + } + }) + .await; + 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}")), + } +} + +pub async fn retry_shaping( + State(state): State, + Extension(login): Extension, +) -> (StatusCode, Json) { + if login != LoginResult::Admin { + return unauthorized(); + } + + let result = tokio::task::spawn_blocking({ + let paths = state.paths.clone(); + let pending_children = state.pending_children.clone(); + move || { + let mut pending = pending_children.blocking_lock(); + retry_shaping_transaction(paths.as_ref(), &mut pending) + } + }) + .await; + 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}")), + } +} diff --git a/src/rust/lqosd/src/node_manager/static2/api.html b/src/rust/lqosd/src/node_manager/static2/api.html index 314fbea3..68912484 100644 --- a/src/rust/lqosd/src/node_manager/static2/api.html +++ b/src/rust/lqosd/src/node_manager/static2/api.html @@ -5,8 +5,8 @@
-
The LibreQoS API is an Insight feature.
- Start an Insight free trial to enable API access and Libby on this node. +
The LibreQoS API requires an entitled license.
+ Review License & Services to enable API access on this node.
@@ -42,8 +42,8 @@

Getting started

- Click the Insight - Free Trial link in the top menu to start a trial and enable the Local API and Libby. - If you already have a key, use the same link to enter it. Once enabled, the Swagger UI is available at /api-docs + Open License & Services from the top menu to enter or review your key. + Once enabled, the Swagger UI is available at /api-docs and Libby at http://<node-ip>:9121/.

@@ -55,8 +55,8 @@ (function(){ const notice = document.getElementById('apiInsightNotice'); if (!notice) return; - const hasInsight = typeof window.hasInsight !== 'undefined' ? !!window.hasInsight : false; - if (!hasInsight) { + const hasApiDocs = typeof window.hasApiDocs !== 'undefined' ? !!window.hasApiDocs : false; + if (!hasApiDocs) { notice.classList.remove('d-none'); } else { notice.classList.add('d-none'); diff --git a/src/rust/lqosd/src/node_manager/static2/chatbot.html b/src/rust/lqosd/src/node_manager/static2/chatbot.html index d3c7778a..88ac3909 100644 --- a/src/rust/lqosd/src/node_manager/static2/chatbot.html +++ b/src/rust/lqosd/src/node_manager/static2/chatbot.html @@ -25,8 +25,8 @@
-
Libby is an Insight feature.
- Start an Insight free trial to enable Libby on this node. +
Libby requires an entitled license.
+ Open License & Services to review availability for this node.
diff --git a/src/rust/lqosd/src/node_manager/static2/config_interface.html b/src/rust/lqosd/src/node_manager/static2/config_interface.html index b827498b..5df08b71 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_interface.html +++ b/src/rust/lqosd/src/node_manager/static2/config_interface.html @@ -1,29 +1,51 @@
+
+ +
-
Network Mode
-
Choose how LibreQoS connects to the network, then configure the required interfaces or VLANs.
+
Bridge & Interface Mode
+
Choose how LibreQoS connects to the network, then inspect the detected netplan state before applying a managed bridge workflow.
Operating Mode
-
Select either a two-interface bridge or a single trunk interface using VLANs.
+
Select either a two-interface bridge or a single trunk interface using VLANs. XDP bridge mode remains manual.
-
Two physical interfaces: one facing the Internet and one facing the LAN.
+
Two physical interfaces: one facing the Internet and one facing the LAN. Linux bridge mode is the managed netplan path.
-
Single physical interface using VLANs (on-a-stick).
+
Single physical interface using VLANs (on-a-stick). Managed netplan preview disables DHCP on the selected interface.
@@ -31,62 +53,184 @@ -
- +
+
+
Draft
+
+ + +
+
+
+
Commit Candidate
+ +
+
+
Import Existing Netplan
+
+ + +
+
+ +
+
+
+
+
Shaping Status
+
Current shaping runtime state, degraded-mode detail, and whether manual intervention is still needed.
+
+ Loading +
+
+

Loading shaping status...

+

+                
+ +
+
+
+ +
+
+
+
Bridge Status
+
Detected Netplan ownership, compatibility warnings, and whether the current mode can be managed safely by LibreQoS.
+
+ Loading +
+
+

Loading netplan inspection...

+
+
+
+
+ +
+
+
+
Review Candidate
+
Use the tabs below to review detected files, the managed candidate LibreQoS will write, and the exact diff before you commit a network change.
+
+
+
+ + +
+
Each relevant file is classified so you can see whether LibreQoS can adopt it, must leave it alone, or treats it as conflicting.
+
Loading detected netplan files...
+
+ +
+
+
+

+                
+ +
+
+
No diff preview available.
+
+
+
+ +
+
+
+
Pending Change
+
Use these controls to explicitly confirm or revert a staged network change before the rollback window expires.
+
+
+
+
No pending network change.
+
+ + +
+
+
+ +
+
+
+
Last Backup
+
Restore the most recent LibreQoS-managed backup if you need to return to the previous committed network configuration.
+
+
+
+
No helper backup recorded yet.
+
+ +
+
+
+
diff --git a/src/rust/lqosd/src/node_manager/static2/config_lts.html b/src/rust/lqosd/src/node_manager/static2/config_lts.html index 52d5ddb1..ac0ecb25 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_lts.html +++ b/src/rust/lqosd/src/node_manager/static2/config_lts.html @@ -5,41 +5,71 @@
-
Insight
-
Control Insight statistics collection, licensing, and optional self-hosted endpoint settings.
+
License & Services
+
Review effective license state, control bootstrap behavior, and manage Insight or Local service settings from one place.
-
Collection and License
-
Enable Insight data gathering and define the cadence used for aggregation and reporting.
+
Effective Status
+
This reflects the current license authority in use, not just what is configured locally.
-
- - -
Enable collection of Insight performance data.
+
+
+
+
+
License State
+
Loading...
+
+ Loading +
+
+
+
Control Service
+
Checking...
+
+
+
Mapped Circuits
+
Checking...
+
+
+
+
Capabilities
+
+
+ +
-
- - -
How frequently to aggregate statistics into Insight records.
-
- -
- - -
License key for LibreQoS Insight, when applicable.
+
+ + + Use this after correcting a key or when you want to force a fresh control-channel attempt. +
-
Endpoints and Reporting
-
Set reporting cadence for UISP synchronization and optionally point LibreQoS at a self-hosted Insight service.
+
Collection and Endpoint Settings
+
Enable long-term stats for entitled tiers and define optional self-hosted service settings.
+ +
+ + +
Enable collection and submission of long-term performance data when the active license tier permits it.
+
+ +
+ + +
How frequently to aggregate statistics into long-term records.
+
@@ -54,6 +84,31 @@
+ +
+
+
License Key
+
Configured locally for control-channel bootstrap and entitlement refresh.
+ +
+
+ +
+ + +
+
Configured license key for Insight or Local service entitlement, when applicable.
+
+
+ +
+
+
+
diff --git a/src/rust/lqosd/src/node_manager/static2/help.html b/src/rust/lqosd/src/node_manager/static2/help.html index 99cedb52..7fd38689 100644 --- a/src/rust/lqosd/src/node_manager/static2/help.html +++ b/src/rust/lqosd/src/node_manager/static2/help.html @@ -8,7 +8,7 @@ Support Tickets
- Priority support is available to Insight subscribers. + Priority support is available to entitled license tiers.
@@ -23,8 +23,8 @@
- To submit a support ticket you need an Insight subscription. - Start an Insight free trial. + To submit a support ticket you need an entitled license. + Open License & Services.
@@ -41,7 +41,7 @@
- Priority support is given to Insight subscribers and donors. Other support is + Priority support is given to entitled license tiers and donors. Other support is best effort, with volunteers trying to help as best we can.
diff --git a/src/rust/lqosd/src/node_manager/static2/lts_trial.html b/src/rust/lqosd/src/node_manager/static2/lts_trial.html index d623a401..3d2d7346 100644 --- a/src/rust/lqosd/src/node_manager/static2/lts_trial.html +++ b/src/rust/lqosd/src/node_manager/static2/lts_trial.html @@ -128,7 +128,6 @@ body.dark-mode .card-header.bg-light {
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index fb9954db..316881cc 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -1997,6 +1997,7 @@ table tr td a { backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 1.1rem 1.15rem; + min-width: 0; } .lqos-config-panel-header { @@ -2030,6 +2031,8 @@ table tr td a { background: color-mix(in srgb, var(--lqos-surface-2) 82%, transparent); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); padding: 1rem 1rem 0.9rem; + min-width: 0; + overflow: hidden; } .lqos-config-section-title { @@ -2075,6 +2078,127 @@ table tr td a { margin-top: 1rem; } +.lqos-config-actions-network-mode { + align-items: stretch; + flex-wrap: wrap; +} + +.lqos-config-action-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: min(100%, 16rem); + padding: 0.85rem 0.95rem; + border: 1px solid var(--lqos-border); + border-radius: var(--lqos-radius-md); + background: color-mix(in srgb, var(--lqos-surface-2) 82%, transparent); +} + +.lqos-config-action-group-primary { + border-color: color-mix(in srgb, var(--bs-info) 32%, var(--lqos-border)); + background: color-mix(in srgb, var(--bs-info-bg-subtle) 42%, var(--lqos-surface-2) 58%); +} + +.lqos-config-action-label { + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.lqos-config-grid-compact { + grid-template-columns: minmax(0, 1fr); +} + +.lqos-config-panel-wide { + grid-column: auto; +} + +.lqos-config-tabbar { + gap: 0.5rem; + overflow-x: auto; + flex-wrap: nowrap; +} + +.lqos-config-tabbar .nav-link { + white-space: nowrap; + border: 1px solid var(--lqos-border); + background: color-mix(in srgb, var(--lqos-surface-2) 78%, transparent); +} + +.lqos-config-tabpanel { + min-width: 0; +} + +.lqos-config-button-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + min-width: 0; +} + +.lqos-config-button-row .btn { + min-width: 0; + max-width: 100%; + white-space: normal; +} + +.lqos-break-anywhere { + overflow-wrap: anywhere; + word-break: break-word; +} + +#pendingChange, +#lastBackup { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +#pendingChange code, +#lastBackup code { + white-space: pre-wrap; +} + +.lqos-backup-entry { + min-width: 0; + overflow: hidden; +} + +.lqos-config-disclosure { + border: 1px dashed color-mix(in srgb, var(--lqos-border) 85%, transparent); + border-radius: var(--lqos-radius-md); + padding: 0.7rem 0.85rem; + background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent); +} + +.lqos-config-disclosure summary { + cursor: pointer; + color: var(--bs-secondary-color); + font-weight: 600; +} + +.lqos-config-disclosure[open] summary { + margin-bottom: 0.35rem; +} + +#lastBackup { + max-height: 13rem; + overflow: auto; + padding-right: 0.25rem; +} + +@media (min-width: 1200px) { + .lqos-config-grid-compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .lqos-config-panel-wide { + grid-column: 1 / -1; + } +} + .lqos-config-panel .card { border: 1px solid var(--lqos-border); border-radius: var(--lqos-radius-md); diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index 259e273f..2df52409 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -1,7 +1,6 @@ //! Provides an Axum layer that applies templates to static HTML //! files. -use crate::lts2_sys::shared_types::LtsStatus; use crate::node_manager::auth::{FIRST_LOAD, get_username}; use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::tool_status::is_api_available; @@ -103,9 +102,6 @@ pub async fn apply_templates( let template_text = template_text.replace("%%USERNAME%%", &username); let res = next.run(req).await; - //let mut lts_script = ""; - let mut script_has_lts = false; - let mut script_has_insight = false; let new_version = crate::version_checks::new_version_available(); if apply_template { @@ -113,20 +109,10 @@ pub async fn apply_templates( let mut trial_link; // Change the LTS part of the template - let (lts_status, _) = crate::lts2_sys::get_lts_license_status_async().await; + let capabilities = crate::lts2_sys::current_capabilities(); trial_link = INSIGHT_LINK_OFFER_TRIAL.to_string(); - let script_has_support_tickets = matches!( - lts_status, - LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full - ); - match lts_status { - LtsStatus::Invalid | LtsStatus::NotChecked => {} - _ => { - // Link to it - trial_link = INSIGHT_LINK_ACTIVE.to_string(); - script_has_insight = true; - script_has_lts = true; - } + if capabilities.can_view_insight_ui { + trial_link = INSIGHT_LINK_ACTIVE.to_string(); } // Title and node_id @@ -136,10 +122,17 @@ pub async fn apply_templates( // "LTS script" - which is increasingly becoming a misnomer let lts_script = format!( - "", - js_tf(script_has_lts), - js_tf(script_has_insight), - js_tf(script_has_support_tickets), + "", + js_tf(capabilities.can_view_insight_ui), + js_tf(capabilities.can_view_insight_ui), + js_tf(capabilities.can_use_support_tickets), + js_tf(capabilities.can_use_chatbot), + js_tf(capabilities.can_use_api_link), + js_tf(capabilities.control_service_reachable), + serde_json::to_string(&capabilities.license_state_label) + .unwrap_or_else(|_| "\"Unknown\"".to_string()), + serde_json::to_string(&capabilities.authority_label) + .unwrap_or_else(|_| "\"Unknown\"".to_string()), node_id_js, rtt_thresholds.green_ms, rtt_thresholds.yellow_ms, @@ -160,7 +153,7 @@ pub async fn apply_templates( .sorted_by(|a, b| a.circuit_hash.cmp(&b.circuit_hash)) .dedup() .count(); - if num_circuits > 1_000 && !script_has_insight { + if num_circuits > 1_000 && !capabilities.can_view_insight_ui { show_modal = "true"; show_modal_number = num_circuits.to_string(); } @@ -182,7 +175,7 @@ pub async fn apply_templates( .replace("%%MODAL%%", show_modal) .replace("%%MODAL_NUM%%", &show_modal_number); // Handle API_LINK placeholder (require service + valid Insight) - let api_link = if is_api_available() && script_has_insight { + let api_link = if is_api_available() && capabilities.can_use_api_link { API_LINK_ACTIVE } else { API_LINK_INACTIVE diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 4ec523ad..65bc0c31 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -714,19 +714,32 @@ async fn receive_channel_message( return true; } } - WsRequest::SupportTicketList => { - if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + WsRequest::SupportTicketList => match lts::support_ticket_gate().await { + Err(StatusCode::FORBIDDEN) => { if send_ws_response( &tx, WsResponse::Error { - message: "Support tickets require an Insight subscription".to_string(), + message: "Support tickets require an entitled license.".to_string(), }, ) .await { return true; } - } else { + } + Err(StatusCode::SERVICE_UNAVAILABLE) => { + if send_ws_response( + &tx, + WsResponse::Error { + message: "license valid, control service unavailable".to_string(), + }, + ) + .await + { + return true; + } + } + _ => { let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); let control_tx = request_state.private_state.control_tx(); if control_tx @@ -741,7 +754,7 @@ async fn receive_channel_message( if send_ws_response( &tx, WsResponse::Error { - message: "Insight control channel unavailable".to_string(), + message: "license valid, control service unavailable".to_string(), }, ) .await @@ -749,8 +762,8 @@ async fn receive_channel_message( return true; } } else { - let result = tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx) - .await; + let result = + tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx).await; match result { Ok(Ok(Ok(tickets))) => { if send_ws_response(&tx, WsResponse::SupportTicketListResult { tickets }) @@ -774,20 +787,33 @@ async fn receive_channel_message( } } } - } - WsRequest::SupportTicketGet { ticket_id } => { - if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + }, + WsRequest::SupportTicketGet { ticket_id } => match lts::support_ticket_gate().await { + Err(StatusCode::FORBIDDEN) => { if send_ws_response( &tx, WsResponse::Error { - message: "Support tickets require an Insight subscription".to_string(), + message: "Support tickets require an entitled license.".to_string(), }, ) .await { return true; } - } else { + } + Err(StatusCode::SERVICE_UNAVAILABLE) => { + if send_ws_response( + &tx, + WsResponse::Error { + message: "license valid, control service unavailable".to_string(), + }, + ) + .await + { + return true; + } + } + _ => { let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); let control_tx = request_state.private_state.control_tx(); if control_tx @@ -803,7 +829,7 @@ async fn receive_channel_message( if send_ws_response( &tx, WsResponse::Error { - message: "Insight control channel unavailable".to_string(), + message: "license valid, control service unavailable".to_string(), }, ) .await @@ -836,7 +862,7 @@ async fn receive_channel_message( } } } - } + }, WsRequest::SupportTicketCreate { subject, priority, @@ -854,11 +880,16 @@ async fn receive_channel_message( { return true; } - } else if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + } else if let Err(status) = lts::support_ticket_gate().await { + let message = match status { + StatusCode::FORBIDDEN => "Support tickets require an entitled license.", + StatusCode::SERVICE_UNAVAILABLE => "license valid, control service unavailable", + _ => "Support tickets unavailable", + }; if send_ws_response( &tx, WsResponse::Error { - message: "Support tickets require an Insight subscription".to_string(), + message: message.to_string(), }, ) .await @@ -884,7 +915,7 @@ async fn receive_channel_message( if send_ws_response( &tx, WsResponse::Error { - message: "Insight control channel unavailable".to_string(), + message: "license valid, control service unavailable".to_string(), }, ) .await @@ -934,11 +965,16 @@ async fn receive_channel_message( { return true; } - } else if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + } else if let Err(status) = lts::support_ticket_gate().await { + let message = match status { + StatusCode::FORBIDDEN => "Support tickets require an entitled license.", + StatusCode::SERVICE_UNAVAILABLE => "license valid, control service unavailable", + _ => "Support tickets unavailable", + }; if send_ws_response( &tx, WsResponse::Error { - message: "Support tickets require an Insight subscription".to_string(), + message: message.to_string(), }, ) .await @@ -968,7 +1004,7 @@ async fn receive_channel_message( if send_ws_response( &tx, WsResponse::Error { - message: "Insight control channel unavailable".to_string(), + message: "license valid, control service unavailable".to_string(), }, ) .await @@ -1049,6 +1085,56 @@ async fn receive_channel_message( return true; } } + WsRequest::LtsCapabilities => match lts::lts_capabilities_data(*request_state.login) { + Ok(data) => { + let response = WsResponse::LtsCapabilitiesResult { data }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(StatusCode::FORBIDDEN) => { + let response = WsResponse::Error { + message: "Unauthorized".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::Error { + message: "Unable to load license status".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + }, + WsRequest::LtsRetryLicenseCheck => { + match lts::retry_license_check_data(*request_state.login) { + Ok(data) => { + let response = WsResponse::LtsCapabilitiesResult { data }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(StatusCode::FORBIDDEN) => { + let response = WsResponse::Error { + message: "Unauthorized".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::Error { + message: "Unable to retry license check".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + } + } WsRequest::LtsStartSignup => { let result = lts::lts_trial_start_signup_data().await; match result { @@ -1107,7 +1193,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1132,7 +1218,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1158,7 +1244,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1185,7 +1271,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1212,7 +1298,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1238,7 +1324,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1264,7 +1350,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1292,7 +1378,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1318,7 +1404,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1345,7 +1431,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1371,7 +1457,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; @@ -1397,7 +1483,7 @@ async fn receive_channel_message( } Err(StatusCode::FORBIDDEN) => { let response = WsResponse::Error { - message: "Insight not enabled".to_string(), + message: "This page requires an entitled license.".to_string(), }; if send_ws_response(&tx, response).await { return true; diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index 462a5109..83634905 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -61,7 +61,9 @@ use crate::throughput_tracker::TcpRetransmitTotal; use crate::throughput_tracker::flow_data::{ AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, }; -use lqos_bus::{Circuit, FlowbeeSummaryData, QueueStoreTransit, StormguardDebugEntry}; +use lqos_bus::{ + Circuit, FlowbeeSummaryData, LtsCapabilitiesSummary, QueueStoreTransit, StormguardDebugEntry, +}; use lqos_config::QooProfileInfo; use lqos_config::{Config, NetworkJsonTransport, ShapedDevice, WebUser}; use lqos_utils::units::DownUpOrder; @@ -144,6 +146,8 @@ pub enum WsRequest { LtsTrialConfig, CircuitCount, LtsStartSignup, + LtsCapabilities, + LtsRetryLicenseCheck, LtsSignUp { license_key: String, }, @@ -778,6 +782,9 @@ pub enum WsResponse { CircuitCountResult { data: CircuitCount, }, + LtsCapabilitiesResult { + data: LtsCapabilitiesSummary, + }, LtsStartSignupResult { claim_id: String, }, diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs index aa8686a5..e80707c8 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs @@ -178,6 +178,22 @@ impl PrivateState { return; } + let capabilities = crate::lts2_sys::current_capabilities(); + if !capabilities.can_use_chatbot || !capabilities.control_service_reachable { + let message = if capabilities.can_use_chatbot { + "license valid, control service unavailable" + } else { + "Libby requires an entitled license." + }; + let response = WsResponse::Error { + message: message.to_string(), + }; + if let Ok(payload) = encode_ws_message(&response) { + let _ = self.tx.send(payload).await; + } + return; + } + let request_id = rand::random::(); self.chatbot_request = Some(request_id); let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::>(64); diff --git a/src/rust/lqosd/src/remote_commands.rs b/src/rust/lqosd/src/remote_commands.rs index ea650a28..0598b2a2 100644 --- a/src/rust/lqosd/src/remote_commands.rs +++ b/src/rust/lqosd/src/remote_commands.rs @@ -20,6 +20,10 @@ pub fn start_remote_commands() { } fn run_command(command: RemoteCommand) { + if !crate::lts2_sys::current_capabilities().can_receive_remote_commands { + warn!("Ignoring remote command because current license tier does not permit it"); + return; + } match command { RemoteCommand::Log(msg) => { warn!("Message from Insight: {}", msg); diff --git a/src/rust/lqosd/src/shaping_runtime.rs b/src/rust/lqosd/src/shaping_runtime.rs new file mode 100644 index 00000000..e3059564 --- /dev/null +++ b/src/rust/lqosd/src/shaping_runtime.rs @@ -0,0 +1,105 @@ +use lqos_utils::unix_time::unix_now; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ShapingRuntimeState { + Active, + Starting, + Inactive, + ErrorPreflight, + ErrorKernelAttach, + ErrorInterfaceMissing, + ErrorConfig, + Degraded, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShapingRuntimeStatus { + pub state: ShapingRuntimeState, + pub degraded: bool, + pub can_retry: bool, + pub summary: String, + pub detail: Option, + pub updated_unix: Option, +} + +impl Default for ShapingRuntimeStatus { + fn default() -> Self { + Self { + state: ShapingRuntimeState::Starting, + degraded: false, + can_retry: false, + summary: "LibreQoS is starting shaping.".to_string(), + detail: None, + updated_unix: None, + } + } +} + +static SHAPING_RUNTIME_STATUS: Lazy> = + Lazy::new(|| RwLock::new(ShapingRuntimeStatus::default())); + +fn now_unix() -> Option { + unix_now().ok() +} + +fn set_status(status: ShapingRuntimeStatus) { + *SHAPING_RUNTIME_STATUS.write() = status; +} + +pub fn mark_starting(summary: impl Into) { + set_status(ShapingRuntimeStatus { + state: ShapingRuntimeState::Starting, + degraded: false, + can_retry: false, + summary: summary.into(), + detail: None, + updated_unix: now_unix(), + }); +} + +pub fn mark_active(summary: impl Into) { + set_status(ShapingRuntimeStatus { + state: ShapingRuntimeState::Active, + degraded: false, + can_retry: false, + summary: summary.into(), + detail: None, + updated_unix: now_unix(), + }); +} + +pub fn mark_error( + state: ShapingRuntimeState, + summary: impl Into, + detail: impl Into, +) { + set_status(ShapingRuntimeStatus { + state, + degraded: true, + can_retry: true, + summary: summary.into(), + detail: Some(detail.into()), + updated_unix: now_unix(), + }); +} + +pub fn get_status() -> ShapingRuntimeStatus { + SHAPING_RUNTIME_STATUS.read().clone() +} + +pub fn classify_preflight_error(detail: &str) -> ShapingRuntimeState { + let lower = detail.to_lowercase(); + if lower.contains("does not exist") || lower.contains("missing") { + ShapingRuntimeState::ErrorInterfaceMissing + } else if lower.contains("configuration") + || lower.contains("/etc/lqos.conf") + || lower.contains("load config") + { + ShapingRuntimeState::ErrorConfig + } else { + ShapingRuntimeState::ErrorPreflight + } +} diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index 0c08eb38..4f9553f9 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -11,7 +11,6 @@ use self::throughput_entry::ThroughputEntry; use crate::system_stats::SystemStats; use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection; use crate::{ - lts2_sys::{get_lts_license_status, shared_types::LtsStatus}, shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}, stats::TIME_TO_POLL_HOSTS, throughput_tracker::tracking_data::{FlowApplyContext, ThroughputTracker}, @@ -1015,10 +1014,7 @@ pub fn executive_summary_header() -> BusResponse { let queue_counts = ALL_QUEUE_SUMMARY.queue_counts(); let bakery_reload_in_progress = full_reload_in_progress(); let queue_stats_stale = queue_stats_stale() || bakery_reload_in_progress; - let insight_connected = !matches!( - get_lts_license_status().0, - LtsStatus::Invalid | LtsStatus::NotChecked - ); + let insight_connected = crate::lts2_sys::current_capabilities().control_service_reachable; BusResponse::ExecutiveSummaryHeader(ExecutiveSummaryHeader { circuit_count, diff --git a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs index 2a452033..60c15b60 100644 --- a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs +++ b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs @@ -1,5 +1,4 @@ -use crate::lts2_sys::get_lts_license_status; -use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks, LtsStatus}; +use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks}; use crate::shaped_devices_tracker::{ NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip, }; @@ -118,13 +117,7 @@ pub(crate) fn submit_throughput_stats( return; } - // Bail out if the license doesn't indicate that we're allowed to submit stats - let (license_status, _days_remaining) = get_lts_license_status(); - let can_submit = !matches!( - license_status, - LtsStatus::NotChecked | LtsStatus::ApiOnly | LtsStatus::Invalid - ); - if !can_submit { + if !crate::lts2_sys::can_submit_long_term_stats() { return; } From 177a8a6609e42f5df872514c4c3ed1f5bc5c4643 Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 03:52:39 +0000 Subject: [PATCH 2/7] changes --- .../src/node_manager/js_build/src/template.js | 2 +- .../static2/config_interface.html | 8 ++-- .../lqosd/src/node_manager/static2/help.html | 17 ++++----- .../src/node_manager/static2/node_manager.css | 38 +++++++++++++++++++ .../src/node_manager/static2/template.html | 2 +- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index 094a99db..4218ebb0 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -792,7 +792,7 @@ function saveQueueModeAndReload(nextMode) { const confirmed = window.confirm( nextMode === "observe" - ? "Switch LibreQoS to Observe mode? This reload will remove the active LibreQoS shaping tree so you can gather a true baseline. It may briefly interrupt traffic." + ? "Switch LibreQoS to Observe mode? LibreQoS will reload without the active shaping tree so you can gather a true baseline. Traffic may briefly pause during the reload, typically for about 5 seconds." : "Switch LibreQoS to Shape mode? This reload will apply the LibreQoS shaping tree and begin active shaping. It may briefly interrupt traffic." ); if (!confirmed) { diff --git a/src/rust/lqosd/src/node_manager/static2/config_interface.html b/src/rust/lqosd/src/node_manager/static2/config_interface.html index 5df08b71..783e185e 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_interface.html +++ b/src/rust/lqosd/src/node_manager/static2/config_interface.html @@ -144,7 +144,7 @@ Loading -
+

Loading shaping status...


                 
@@ -161,7 +161,7 @@
Loading
-
+

Loading netplan inspection...

@@ -207,7 +207,7 @@
Use these controls to explicitly confirm or revert a staged network change before the rollback window expires.
-
+
No pending network change.
@@ -223,7 +223,7 @@
Restore the most recent LibreQoS-managed backup if you need to return to the previous committed network configuration.
-
+
No helper backup recorded yet.
diff --git a/src/rust/lqosd/src/node_manager/static2/help.html b/src/rust/lqosd/src/node_manager/static2/help.html index 7fd38689..9fac25f4 100644 --- a/src/rust/lqosd/src/node_manager/static2/help.html +++ b/src/rust/lqosd/src/node_manager/static2/help.html @@ -37,19 +37,18 @@
-
Help & Support
+
About LibreQoS
-
- - Priority support is given to entitled license tiers and donors. Other support is - best effort, with volunteers trying to help as best we can. -
+

+ Learn more about the project and the people who helped shape it. +

diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 316881cc..a6afb409 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -2571,3 +2571,41 @@ table tr td a { grid-template-columns: repeat(3, minmax(0, 1fr)); } } + +.btn-lqos-memorial { + color: #fff; + background: linear-gradient(135deg, #58667d 0%, #39475a 100%); + border-color: #39475a; + box-shadow: 0 0.35rem 0.9rem rgba(57, 71, 90, 0.18); +} + +.btn-lqos-memorial:hover, +.btn-lqos-memorial:focus, +.btn-lqos-memorial:active, +.btn-check:checked + .btn-lqos-memorial, +.btn-check:active + .btn-lqos-memorial, +.btn-lqos-memorial.show { + color: #fff; + background: linear-gradient(135deg, #4a576c 0%, #2f3c4d 100%); + border-color: #2f3c4d; +} + +.btn-lqos-memorial:focus-visible { + box-shadow: 0 0 0 0.25rem rgba(88, 102, 125, 0.28); +} + +html[data-bs-theme="dark"] .btn-lqos-memorial { + background: linear-gradient(135deg, #6b7d9a 0%, #4d5f79 100%); + border-color: #7d90ae; + box-shadow: 0 0.35rem 0.9rem rgba(5, 8, 15, 0.35); +} + +html[data-bs-theme="dark"] .btn-lqos-memorial:hover, +html[data-bs-theme="dark"] .btn-lqos-memorial:focus, +html[data-bs-theme="dark"] .btn-lqos-memorial:active, +html[data-bs-theme="dark"] .btn-check:checked + .btn-lqos-memorial, +html[data-bs-theme="dark"] .btn-check:active + .btn-lqos-memorial, +html[data-bs-theme="dark"] .btn-lqos-memorial.show { + background: linear-gradient(135deg, #7a8fb3 0%, #5d7290 100%); + border-color: #8ea3c4; +} diff --git a/src/rust/lqosd/src/node_manager/static2/template.html b/src/rust/lqosd/src/node_manager/static2/template.html index 28bba6a3..d1c84185 100644 --- a/src/rust/lqosd/src/node_manager/static2/template.html +++ b/src/rust/lqosd/src/node_manager/static2/template.html @@ -180,7 +180,7 @@
-
Reload required. Switching modes may briefly interrupt traffic.
+
Reload required. Switching modes typically cause a brief traffic pause of about 5 seconds.

From 4cd2c7eeaed91528df75e827b02529e9d864bb7f Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 04:08:28 +0000 Subject: [PATCH 3/7] changes --- src/rust/lqos_config/src/network_json.rs | 2 +- .../src/topology_canonical_state.rs | 4 +- .../js_build/src/toasts/global_warnings.js | 187 ------------------ .../js_build/src/toasts/sponsor_us.js | 66 +++++-- .../node_manager/local_api/device_counts.rs | 13 +- .../src/node_manager/static2/template.html | 1 - src/rust/lqosd/src/node_manager/template.rs | 35 +--- 7 files changed, 69 insertions(+), 239 deletions(-) diff --git a/src/rust/lqos_config/src/network_json.rs b/src/rust/lqos_config/src/network_json.rs index d340bb6d..a6615689 100644 --- a/src/rust/lqos_config/src/network_json.rs +++ b/src/rust/lqos_config/src/network_json.rs @@ -922,7 +922,7 @@ mod test { config.uisp_integration.enable_uisp = true; let selected = NetworkJson::path_for_config(&config); - assert_eq!(selected, temp.join("network.effective.json")); + assert_eq!(selected, config.topology_state_file_path("network.effective.json")); } #[test] diff --git a/src/rust/lqos_config/src/topology_canonical_state.rs b/src/rust/lqos_config/src/topology_canonical_state.rs index d634b227..16d5177d 100644 --- a/src/rust/lqos_config/src/topology_canonical_state.rs +++ b/src/rust/lqos_config/src/topology_canonical_state.rs @@ -1488,7 +1488,7 @@ mod tests { .exists() ); - let quarantine_directory = lqos_directory.join(".topology_stale"); + let quarantine_directory = config.quarantine_state_directory_path(); let quarantined = fs::read_dir(&quarantine_directory) .expect("directory should read") .filter_map(|entry| entry.ok()) @@ -1628,7 +1628,7 @@ mod tests { .exists() ); assert!(lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME).exists()); - let quarantine_directory = lqos_directory.join(".topology_stale"); + let quarantine_directory = config.quarantine_state_directory_path(); let quarantined = if quarantine_directory.exists() { fs::read_dir(&quarantine_directory) .expect("directory should read") diff --git a/src/rust/lqosd/src/node_manager/js_build/src/toasts/global_warnings.js b/src/rust/lqosd/src/node_manager/js_build/src/toasts/global_warnings.js index 9a01c569..f8ba80f0 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/toasts/global_warnings.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/toasts/global_warnings.js @@ -1,12 +1,6 @@ import {createBootstrapToast} from "../lq_js_common/helpers/toasts"; -import {PLACEHOLDER_TEASERS} from "../lts_teasers_shared"; import {get_ws_client} from "../pubsub/ws"; -let insightModalShown = false; - -let modalTeasers = []; -let modalTeasersLoaded = false; -let modalLtsBaseUrl = "https://insight.libreqos.com/"; const wsClient = get_ws_client(); const listenOnce = (eventName, handler) => { @@ -17,188 +11,7 @@ const listenOnce = (eventName, handler) => { wsClient.on(eventName, wrapped); }; -function sendWsRequest(responseEvent, request) { - return new Promise((resolve, reject) => { - let done = false; - const onResponse = (msg) => { - if (done) return; - done = true; - wsClient.off(responseEvent, onResponse); - wsClient.off("Error", onError); - resolve(msg); - }; - const onError = (msg) => { - if (done) return; - done = true; - wsClient.off(responseEvent, onResponse); - wsClient.off("Error", onError); - reject(msg); - }; - wsClient.on(responseEvent, onResponse); - wsClient.on("Error", onError); - wsClient.send(request); - }); -} - -function getModalLtsUrl(endpoint) { - let baseUrl = modalLtsBaseUrl; - if (!/^https?:\/\//i.test(baseUrl)) { - baseUrl = "https://" + baseUrl; - } - let base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; - base += base.endsWith("signup-api/") ? "" : "signup-api/"; - endpoint = endpoint.replace(/^\/+/, ""); - return base + endpoint; -} - -async function loadModalTeasers() { - if (modalTeasersLoaded) { - renderModalTeasers(); - return; - } - - try { - const response = await sendWsRequest("LtsTrialConfigResult", { LtsTrialConfig: {} }); - const data = response && response.data ? response.data : {}; - if (data.lts_url && typeof data.lts_url === "string") { - modalLtsBaseUrl = data.lts_url; - } - } catch (e) { - // If this fails, we just fall back to the default base URL. - console.error("Failed to fetch LTS base URL for modal teasers:", e); - } - - try { - const response = await $.get(getModalLtsUrl("teasers")); - if (response && Array.isArray(response.teasers) && response.teasers.length > 0) { - modalTeasers = response.teasers.map((teaser) => { - const copy = { ...teaser }; - if (copy.image) { - const img = typeof copy.image === "string" ? copy.image : ""; - const cleaned = img.replace(/^\/?signup-api\//, ""); - copy.image = getModalLtsUrl(cleaned); - } - return copy; - }); - } else { - modalTeasers = PLACEHOLDER_TEASERS.slice(); - } - } catch (e) { - console.error("Failed to load Insight teasers for modal:", e); - modalTeasers = PLACEHOLDER_TEASERS.slice(); - } - - modalTeasersLoaded = true; - renderModalTeasers(); -} - -function renderModalTeasers() { - const container = document.getElementById("insightTeaserRow"); - if (!container) { - return; - } - - const teasers = (modalTeasers.length ? modalTeasers : PLACEHOLDER_TEASERS).slice(); - teasers.sort((a, b) => (a.order || 0) - (b.order || 0)); - - const cardsHtml = teasers - .map((teaser) => { - const imageSrc = teaser.image || teaser.imageUrl || ""; - const title = teaser.title || ""; - const description = teaser.description || ""; - return ` -
-
- ${ - imageSrc - ? `${title}` - : "" - } -
-
${title}
-

${description}

-
-
-
- `; - }) - .join(""); - - container.innerHTML = cardsHtml; -} - -function showInsightTrialModal() { - if (insightModalShown) { - return; - } - insightModalShown = true; - - if (!window.bootstrap) { - return; - } - - if (!document.getElementById("insightTrialModal")) { - const modalHtml = ` - - `; - document.body.insertAdjacentHTML( - "beforeend", - modalHtml.replace("{{window.smn}}", window.smn || "?"), - ); - } - - const modalEl = document.getElementById("insightTrialModal"); - if (!modalEl) { - return; - } - - // Load and render teaser cards inside the modal - loadModalTeasers(); - - const modal = new bootstrap.Modal(modalEl, { focus: true }); - modal.show(); -} - export function globalWarningToasts() { - if (window.sm) { - showInsightTrialModal(); - } const handler = (msg) => { wsClient.off("GlobalWarnings", handler); const warnings = msg && msg.data ? msg.data : []; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/toasts/sponsor_us.js b/src/rust/lqosd/src/node_manager/js_build/src/toasts/sponsor_us.js index f3fc9d20..26d62753 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/toasts/sponsor_us.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/toasts/sponsor_us.js @@ -1,6 +1,5 @@ import { get_ws_client } from "../pubsub/ws"; -const sponsorBtn = " Sponsor Us on GitHub"; const sponsorMessages = [ "LibreQoS handles shaping. Insight adds history, dashboards, and alerts so you catch issues before tickets arrive.", "Need proof before or after changes? Insight keeps latency, retransmit, and flow history in one place.", @@ -12,21 +11,54 @@ const sponsorMessages = [ "See circuit and site behavior over time, not just right now. Insight gives you historical context.", ]; -export function sponsorTag(parentId) { - if (!window.hasLts) { - const client = get_ws_client(); - const handler = () => { - if (!window.hasLts) { - let div = document.createElement("div"); - let random = Math.floor(Math.random() * sponsorMessages.length); - div.innerHTML = sponsorMessages[random]; - div.classList.add("alert", "alert-warning", "toasty"); - let parent = document.getElementById(parentId); - parent.appendChild(div); - } - client.off("DeviceCount", handler); - }; - client.on("DeviceCount", handler); - client.send({ DeviceCount: {} }); +function getSponsorState(mappedCircuits) { + if (window.mappedCircuitLimit !== 1000) { + return null; } + + if (mappedCircuits > 1000) { + return { + message: "This deployment is above the unlicensed 1,000 mapped circuit limit; add a license to remove the cap.", + alertClass: "alert-danger", + }; + } + + if (mappedCircuits >= 800) { + return { + message: "Unlicensed deployments are limited to 1,000 mapped circuits, and this system is approaching that limit.", + alertClass: "alert-warning", + }; + } + + return { + message: sponsorMessages[Math.floor(Math.random() * sponsorMessages.length)], + alertClass: "alert-success", + }; +} + +export function sponsorTag(parentId) { + const client = get_ws_client(); + const handler = (msg) => { + client.off("DeviceCount", handler); + + const parent = document.getElementById(parentId); + if (!parent) { + return; + } + + const mappedCircuits = Number(msg?.data?.mapped_circuits ?? 0); + const sponsorState = getSponsorState(mappedCircuits); + if (!sponsorState) { + return; + } + + const div = document.createElement("div"); + div.textContent = sponsorState.message; + div.classList.add("alert", sponsorState.alertClass, "toasty"); + div.setAttribute("role", "alert"); + parent.appendChild(div); + }; + + client.on("DeviceCount", handler); + client.send({ DeviceCount: {} }); } diff --git a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs index 6617aceb..be93916a 100644 --- a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs @@ -1,16 +1,27 @@ use crate::node_manager::local_api::unknown_ips::get_unknown_ips; use crate::shaped_devices_tracker::SHAPED_DEVICES; use serde::Serialize; +use std::collections::BTreeSet; #[derive(Serialize, Debug, Clone)] pub struct DeviceCount { pub shaped_devices: usize, pub unknown_ips: usize, + pub mapped_circuits: usize, } pub fn device_count() -> DeviceCount { + let shaped_devices = SHAPED_DEVICES.load(); + let mapped_circuits = shaped_devices + .devices + .iter() + .map(|device| device.circuit_hash) + .collect::>() + .len(); + DeviceCount { - shaped_devices: SHAPED_DEVICES.load().devices.len(), + shaped_devices: shaped_devices.devices.len(), unknown_ips: get_unknown_ips().len(), + mapped_circuits, } } diff --git a/src/rust/lqosd/src/node_manager/static2/template.html b/src/rust/lqosd/src/node_manager/static2/template.html index d1c84185..cb4cfab3 100644 --- a/src/rust/lqosd/src/node_manager/static2/template.html +++ b/src/rust/lqosd/src/node_manager/static2/template.html @@ -32,7 +32,6 @@ %%%LTS_SCRIPT%%% -
diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index 2df52409..d0eeab11 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -1,8 +1,7 @@ //! Provides an Axum layer that applies templates to static HTML //! files. -use crate::node_manager::auth::{FIRST_LOAD, get_username}; -use crate::shaped_devices_tracker::SHAPED_DEVICES; +use crate::node_manager::auth::get_username; use crate::tool_status::is_api_available; use axum::body::{Body, to_bytes}; use axum::http::header; @@ -10,11 +9,8 @@ use axum::http::{HeaderValue, Request, Response, StatusCode}; use axum::middleware::Next; use axum::response::IntoResponse; use axum_extra::extract::CookieJar; -use itertools::Itertools; use lqos_config::{RttThresholds, load_config}; -use lqos_utils::unix_time::unix_now; use std::path::Path; -use std::sync::atomic::Ordering::Relaxed; const VERSION_STRING: &str = include_str!("../../../../VERSION_STRING"); @@ -122,7 +118,7 @@ pub async fn apply_templates( // "LTS script" - which is increasingly becoming a misnomer let lts_script = format!( - "", + "", js_tf(capabilities.can_view_insight_ui), js_tf(capabilities.can_view_insight_ui), js_tf(capabilities.can_use_support_tickets), @@ -133,33 +129,14 @@ pub async fn apply_templates( .unwrap_or_else(|_| "\"Unknown\"".to_string()), serde_json::to_string(&capabilities.authority_label) .unwrap_or_else(|_| "\"Unknown\"".to_string()), + serde_json::to_string(&capabilities.mapped_circuit_limit) + .unwrap_or_else(|_| "null".to_string()), node_id_js, rtt_thresholds.green_ms, rtt_thresholds.yellow_ms, rtt_thresholds.red_ms, ); - // First Login - let mut show_modal = "false"; - let mut show_modal_number = "0".to_string(); - if let Ok(now) = unix_now() { - let week_ago = now - (7 * 24 * 60 * 60); - let fl = FIRST_LOAD.load(Relaxed); - if fl != 0 && fl < week_ago { - let sd = SHAPED_DEVICES.load(); - let num_circuits = sd - .devices - .iter() - .sorted_by(|a, b| a.circuit_hash.cmp(&b.circuit_hash)) - .dedup() - .count(); - if num_circuits > 1_000 && !capabilities.can_view_insight_ui { - show_modal = "true"; - show_modal_number = num_circuits.to_string(); - } - } - } - let (mut res_parts, res_body) = res.into_parts(); let bytes = to_bytes(res_body, 1_000_000) .await @@ -171,9 +148,7 @@ pub async fn apply_templates( .replace("%%VERSION%%", version_string) .replace("%%TITLE%%", &title) .replace("%%LTS_LINK%%", &trial_link) - .replace("%%%LTS_SCRIPT%%%", <s_script) - .replace("%%MODAL%%", show_modal) - .replace("%%MODAL_NUM%%", &show_modal_number); + .replace("%%%LTS_SCRIPT%%%", <s_script); // Handle API_LINK placeholder (require service + valid Insight) let api_link = if is_api_available() && capabilities.can_use_api_link { API_LINK_ACTIVE From e37e5501116fdab5eaf647d823e65430b7cd8122 Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 04:10:49 +0000 Subject: [PATCH 4/7] changes --- src/scheduler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/scheduler.py b/src/scheduler.py index 067dd9db..8c9689c7 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -56,8 +56,11 @@ def configure_scheduler_stdio(): continue try: reconfigure(line_buffering=True, write_through=True) - except Exception: - continue + except Exception as exc: + print( + f"Warning: unable to enable line-buffered scheduler {stream_name}: {exc}", + file=sys.stderr, + ) def get_state_directory(): From 3b9b8e89664cec365e4e739a6b2b1cd6bf299adc Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 04:17:04 +0000 Subject: [PATCH 5/7] changes --- .../lqos_netplan_helper/src/transaction.rs | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/rust/lqos_netplan_helper/src/transaction.rs b/src/rust/lqos_netplan_helper/src/transaction.rs index 5b8b11f6..0d4b3b2a 100644 --- a/src/rust/lqos_netplan_helper/src/transaction.rs +++ b/src/rust/lqos_netplan_helper/src/transaction.rs @@ -1006,12 +1006,35 @@ esac write_config_to_path(path, config).expect("write config"); } + fn test_interfaces() -> (String, String) { + let interfaces = system_interfaces(); + let primary = interfaces + .iter() + .find(|iface| iface.as_str() != "lo") + .cloned() + .or_else(|| interfaces.iter().next().cloned()) + .expect("test host should expose at least one interface"); + let secondary = interfaces + .iter() + .find(|iface| iface.as_str() != primary && iface.as_str() != "lo") + .cloned() + .or_else(|| { + interfaces + .iter() + .find(|iface| iface.as_str() != primary) + .cloned() + }) + .expect("test host should expose a second distinct interface"); + (primary, secondary) + } + fn linux_bridge_config() -> Config { + let (to_internet, to_network) = test_interfaces(); Config { bridge: Some(lqos_config::BridgeConfig { use_xdp_bridge: false, - to_internet: "ens19".to_string(), - to_network: "ens20".to_string(), + to_internet, + to_network, }), single_interface: None, ..Config::default() @@ -1086,7 +1109,7 @@ esac let mut next = existing.clone(); next.single_interface = Some(lqos_config::SingleInterfaceConfig { - interface: "ens19".to_string(), + interface: test_interfaces().0, internet_vlan: 2, network_vlan: 3, }); @@ -1121,7 +1144,7 @@ esac &paths.config_path, &Config { single_interface: Some(lqos_config::SingleInterfaceConfig { - interface: "ens31".to_string(), + interface: test_interfaces().1, internet_vlan: 10, network_vlan: 11, }), @@ -1202,7 +1225,7 @@ esac let mut next = existing.clone(); next.single_interface = Some(lqos_config::SingleInterfaceConfig { - interface: "ens19".to_string(), + interface: test_interfaces().0, internet_vlan: 2, network_vlan: 3, }); From e9bf9ede8aa993056f6b37a33749e77eadab3f6f Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 04:24:07 +0000 Subject: [PATCH 6/7] changes --- src/rust/uisp_integration/src/ethernet_advisory.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rust/uisp_integration/src/ethernet_advisory.rs b/src/rust/uisp_integration/src/ethernet_advisory.rs index c93a08be..5ff898aa 100644 --- a/src/rust/uisp_integration/src/ethernet_advisory.rs +++ b/src/rust/uisp_integration/src/ethernet_advisory.rs @@ -406,11 +406,12 @@ mod tests { let temp_dir = std::env::temp_dir().join(format!("lqos-ethernet-writer-{unique}")); fs::create_dir_all(&temp_dir).expect("temp dir should be created"); config.lqos_directory = temp_dir.to_string_lossy().to_string(); + config.state_directory = Some(temp_dir.join("state").to_string_lossy().to_string()); write_ethernet_advisories(&config, &[capped.clone(), observed]) .expect("writer should succeed"); - let payload = fs::read(temp_dir.join(CIRCUIT_ETHERNET_METADATA_FILENAME)) + let payload = fs::read(config.topology_state_file_path(CIRCUIT_ETHERNET_METADATA_FILENAME)) .expect("metadata file should exist"); let parsed: CircuitEthernetMetadataFile = serde_json::from_slice(&payload).expect("metadata file should parse"); From 493fffd8fbf6eb05f7900b95edd59928380d7278 Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 13 Apr 2026 04:36:16 +0000 Subject: [PATCH 7/7] changes --- src/build_dpkg.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index c7f6a8ce..4355bf2b 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -38,7 +38,6 @@ LQOS_FILES=( network.example.json pythonCheck.py qoo_profiles.json - README.md scheduler.py ShapedDevices.example.csv shaping_skip_report.py