mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 13:57:40 -05:00
322 files changed
+53312
-9770
No files matched your search
@@ -4,8 +4,8 @@ jobs:
|
||||
lint_python:
|
||||
runs-on: lq_run
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install --upgrade pip wheel
|
||||
|
||||
+28
-13
@@ -4,23 +4,41 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
runs-on: [ lq_run ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set Version ENV
|
||||
run: pushd src; echo "::set-env name=VERSION::$(cat VERSION_STRING)"
|
||||
env:
|
||||
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||
run: echo "VERSION=$(cat src/VERSION_STRING)" >> "$GITHUB_ENV"
|
||||
- name: Check env var
|
||||
run: |
|
||||
echo ${{ env.VERSION }}
|
||||
- name: Install dependencies
|
||||
run: sudo /bin/apt-get update; sudo /bin/apt-get install --no-install-recommends python3-pip clang gcc gcc-multilib llvm libelf-dev git nano graphviz esbuild curl screen llvm pkg-config linux-tools-common linux-tools-`/bin/uname r` libbpf-dev mold curl unzip
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo /bin/apt-get update
|
||||
sudo /bin/apt-get install --no-install-recommends \
|
||||
python3-pip clang gcc gcc-multilib llvm libelf-dev graphviz esbuild \
|
||||
pkg-config libbpf-dev mold unzip curl ca-certificates
|
||||
|
||||
BPFTOOL_VERSION="7.7.0"
|
||||
case "$(uname -m)" in
|
||||
x86_64) BPFTOOL_ARCH="amd64" ;;
|
||||
aarch64) BPFTOOL_ARCH="arm64" ;;
|
||||
*)
|
||||
echo "Unsupported architecture for upstream bpftool binary: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
BPFTOOL_TARBALL="bpftool-v${BPFTOOL_VERSION}-${BPFTOOL_ARCH}.tar.gz"
|
||||
BPFTOOL_BASE_URL="https://github.com/libbpf/bpftool/releases/download/v${BPFTOOL_VERSION}"
|
||||
curl -fsSLO "${BPFTOOL_BASE_URL}/${BPFTOOL_TARBALL}"
|
||||
curl -fsSLO "${BPFTOOL_BASE_URL}/${BPFTOOL_TARBALL}.sha256sum"
|
||||
sha256sum -c "${BPFTOOL_TARBALL}.sha256sum"
|
||||
tar -xzf "${BPFTOOL_TARBALL}"
|
||||
sudo install -m 0755 bpftool /usr/local/bin/bpftool
|
||||
command -v bpftool
|
||||
bpftool version
|
||||
- name: Build
|
||||
run: pushd src/rust; cargo build --verbose --all; popd
|
||||
- name: Run tests
|
||||
@@ -35,13 +53,10 @@ jobs:
|
||||
path: src/dist/libreqos_${{ env.VERSION }}.-1_amd64.deb
|
||||
|
||||
audit:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: pushd src/rust; cargo install cargo-audit; popd
|
||||
- name: Audit for CVEs
|
||||
|
||||
@@ -38,7 +38,7 @@ def ultimateReplace(app, docname, source):
|
||||
source[0] = result
|
||||
|
||||
ultimate_replacements = {
|
||||
"{deb_url_v1_5}" : "libreqos_2.0-beta-1.202603181919-1_amd64.deb"
|
||||
"{deb_url_v1_5}" : "libreqos_2.1.202603311003-1_amd64.deb"
|
||||
}
|
||||
|
||||
def setup(app):
|
||||
|
||||
@@ -84,6 +84,8 @@ sudo cp /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.se
|
||||
sudo cp /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service
|
||||
```
|
||||
|
||||
Estas plantillas esperan `network-online.target`, por lo que `lqosd` y `lqos_scheduler` no arrancan antes de que DNS y la ruta por defecto esten listas.
|
||||
|
||||
Finally, run
|
||||
|
||||
```shell
|
||||
|
||||
@@ -127,6 +127,8 @@ If a network.json file exists, it will not be overwritten, unless you set ```alw
|
||||
|
||||
ShapedDevices.csv will be overwritten every time the UISP integration is run.
|
||||
|
||||
Cuando varios sitios cliente de UISP comparten el mismo nombre, LibreQoS ahora intenta diferenciar los nombres visibles generados para circuitos/sitios con un sufijo más humano, como el primer segmento de la dirección, recurriendo al nombre del servicio y luego a un ID corto solo cuando hace falta. La identidad estable del circuito sigue viniendo del ID de sitio/servicio de UISP, no del nombre visible.
|
||||
|
||||
To ensure the network.json is always overwritten with the newest version pulled in by the integration, please edit `/etc/lqos.conf` with the command `sudo nano /etc/lqos.conf`.
|
||||
Edit the file to set the value of `always_overwrite_network_json` to `true`.
|
||||
Then, run `sudo systemctl restart lqosd`.
|
||||
|
||||
@@ -34,6 +34,8 @@ sudo systemctl enable lqos_api
|
||||
sudo systemctl start lqos_api
|
||||
```
|
||||
|
||||
La plantilla incluida de `lqos_api.service.example` espera `network-online.target`, para que la API no arranque antes de que DNS y la ruta por defecto esten listas.
|
||||
|
||||
Actualizar solo el binario de la API:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -25,13 +25,21 @@ Para la mayoría de operadores:
|
||||
|
||||
La mayoría de cambios operativos diarios se realizan en la WebUI (`http://tu_ip_del_shaper:9123/config_general.html`).
|
||||
|
||||
Las versiones actuales usan paneles compartidos de configuración en las páginas General, RTT, Queues, TreeGuard, Network Mode, valores por defecto de integración, Network Layout, Insight, páginas de integración por proveedor, IP Ranges, Flow Tracking y Shaped Devices para que los ajustes del nodo y la afinación operativa sigan la misma presentación visual que el resto de Node Manager. La página de TreeGuard comienza con un resumen del comportamiento actual en lugar de un banner permanente de advertencia.
|
||||
|
||||
### Dónde en la WebUI
|
||||
|
||||
- Ajustes generales: `Configuration -> General`
|
||||
- Ajustes de integración: `Configuration -> Integrations`
|
||||
- Editor de layout de red: `Configuration -> Network Layout`
|
||||
- Editor de dispositivos regulados: `Configuration -> Shaped Devices`
|
||||
- Validación operativa: páginas de `WebUI (Node Manager)` (dashboard/tree/flow/scheduler)
|
||||
|
||||
Cuando una integración está habilitada y actúa como fuente de verdad, los editores `Network Layout` y `Shaped Devices` siguen siendo visibles pero pasan a modo de solo lectura en la WebUI.
|
||||
|
||||
Nota de topología:
|
||||
- Los nombres de nodo en `network.json` deben ser globalmente únicos en todo el árbol. Los nombres duplicados ahora fallan la validación y no son aceptados por el guardado de la WebUI ni por `LibreQoS.py`.
|
||||
|
||||
## Modos de operación y fuente de verdad
|
||||
|
||||
Lea esto primero antes de cambios en producción:
|
||||
|
||||
@@ -83,6 +83,8 @@ sudo cp /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.se
|
||||
sudo cp /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service
|
||||
```
|
||||
|
||||
Estas plantillas esperan `network-online.target`, por lo que `lqosd` y `lqos_scheduler` no arrancan antes de que DNS y la ruta por defecto esten listas.
|
||||
|
||||
Finalmente, ejecute:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -242,6 +242,7 @@ Efecto práctico:
|
||||
|---|---|---|---|
|
||||
| Cambio solo de IP de circuito | Sí | No | Actualización de mapeo puede aplicarse sin reconstruir árbol |
|
||||
| Cambio de velocidad de circuito/sitio (subconjunto) | Sí | A veces | Depende del impacto estructural y class handles disponibles |
|
||||
| Virtualización runtime de nodos TreeGuard (subárbol soportado / ruta de rebalanceo top-level) | Sí | No | Bakery ahora la aplica como un plan runtime vivo: nodos no top-level usan reparent/prune/restore, y nodos top-level soportados usan una ruta de rebalanceo y migración entre colas |
|
||||
| Cambios masivos en todos los circuitos | A veces | Frecuente | Límites de escala y cardinalidad de transacciones |
|
||||
| Re-parent/reestructura topológica | Rara vez | Sí | Restricciones de mutación de subárbol HTB |
|
||||
| Alta/baja de circuitos | Sí (pequeño/mediano) | A veces | Límites de handles y fronteras de corrección en el diff |
|
||||
@@ -269,6 +270,15 @@ flowchart TD
|
||||
3. Esperar mayor riesgo cuando coinciden muchos circuitos y muchos cambios estructurales.
|
||||
4. Diseñar la cadencia operativa para priorizar cambios incrementalmente seguros.
|
||||
|
||||
### 7.4 Guardrails de seguridad para recargas completas
|
||||
|
||||
Las recargas completas actuales de Bakery aplican dos verificaciones conservadoras de seguridad antes y durante reconstrucciones grandes de colas:
|
||||
|
||||
1. Un preflight de qdisc estima los qdisc planificados por interfaz y además separa qdisc de infraestructura, hojas `cake` y hojas `fq_codel`.
|
||||
2. Ese mismo preflight aplica una proyección conservadora de memoria y bloquea de forma estricta las recargas completas claramente inseguras antes de arrancar `tc -batch`.
|
||||
3. Durante la aplicación chunked de una recarga completa, Bakery vuelve a revisar la memoria del host en los límites de chunk y aborta el resto de la aplicación si la memoria disponible cae por debajo de su piso de seguridad.
|
||||
4. Estos guardrails están sesgados intencionalmente hacia falsos positivos en recargas grandes para fallar temprano con diagnósticos en lugar de entrar en una espiral OOM.
|
||||
|
||||
## 8) Límites de Diseño para Operadores
|
||||
|
||||
### 8.1 Límites de observabilidad
|
||||
|
||||
@@ -7,18 +7,43 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
|
||||
### Dashboard
|
||||
- Resumen por widgets de throughput, retransmisiones, RTT, flujos y actividad de colas.
|
||||
- El contenido puede variar según versión y funciones habilitadas.
|
||||
- Executive Summary ofrece una vista operativa compacta para redes grandes, con un `Network Snapshot` centrado en throughput, inventario y estado de Insight, además de páginas de detalle para heatmaps y rankings ejecutivos.
|
||||
- Bakery ofrece una pestaña dedicada para el estado de aplicación de colas, resultados de seguridad/preflight de qdisc, progreso de cambios en vivo sobre circuitos y operaciones recientes de Bakery.
|
||||
- Las pestañas de Bakery y TreeGuard presentan un resumen de alto nivel del pipeline o control loop antes de las tablas más detalladas.
|
||||
- El widget `Pipeline` de Bakery muestra las etapas del control de colas, el estado de aplicación, el estado de verificación y la temporización del intervalo de TC.
|
||||
- `Runtime Operations` resume mutaciones de topología entre TreeGuard y Bakery, trabajo de limpieza diferida, fallos y subárboles que esperan un full reload.
|
||||
- `Recent Bakery Events` prioriza operaciones agrupadas, con el historial detallado disponible cuando hace falta troubleshooting más profundo.
|
||||
- `TreeGuard Activity` prioriza operaciones agrupadas, incluyendo lotes de cambios SQM, con el historial detallado disponible cuando hace falta troubleshooting más profundo.
|
||||
- `TreeGuard Control Loop` muestra el estado actual de observar/evaluar/actuar.
|
||||
- `TreeGuard Decision Impact` se centra en el impacto actual y en las advertencias o errores activos.
|
||||
- `TreeGuard State Mix` muestra nodos gestionados, virtualización en runtime, circuitos gestionados y la mezcla de circuitos `cake / mixed / fq_codel`.
|
||||
- El preflight de qdisc de Bakery resume el uso planificado de qdisc por interfaz y el margen de presupuesto antes de aplicar cambios.
|
||||
- Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios.
|
||||
- Durante un full reload de Bakery, las tarjetas de conteo de colas pueden seguir mostrando temporalmente los últimos valores conocidos de HTB/CAKE/fq-codel y marcarlos como `Reloading`.
|
||||
|
||||
### Vista de árbol de red
|
||||
- Vista jerárquica de nodos/circuitos desde la perspectiva del shaper.
|
||||
- Útil para identificar cuellos de botella y patrones de utilización padre/hijo.
|
||||
- Las páginas de detalle del árbol muestran una ruta tipo breadcrumb, conteos de rama e indicadores de estado para el nodo seleccionado.
|
||||
- `Node Details` resume el tipo de nodo seleccionado, el tamaño de la rama, las velocidades configuradas y la velocidad efectiva.
|
||||
- `Node Snapshot` ofrece un resumen visual rápido del throughput y el QoO del nodo seleccionado.
|
||||
- Los circuitos adjuntos se muestran en una tabla dedicada para el nodo seleccionado.
|
||||
- La columna de IP de circuitos adjuntos mantiene las filas compactas mostrando una dirección inline y colapsando las adicionales como `+X`, mientras la lista completa sigue disponible al pasar el cursor.
|
||||
- Los circuitos adjuntos limitados por Ethernet pueden mostrar insignias `10M`, `100M` o `1G` junto al valor de `Plan (Mbps)`; al pasar el cursor se explica el auto-cap y al hacer clic en la insignia se abre la página dedicada de revisión Ethernet.
|
||||
- Los administradores pueden guardar o limpiar valores de `Operator Override` cuando el nodo admite overrides a nivel de nodo. Los usuarios de solo lectura y los nodos no compatibles siguen mostrando los valores actuales sin controles de edición.
|
||||
|
||||
### Site Map
|
||||
- Mapa operativo plano de sitios y APs usando geodatos importados de nodos.
|
||||
- Usa QoO por defecto con un selector alternativo para RTT, mientras el tamaño del marcador refleja el throughput combinado reciente.
|
||||
- Usa un promedio del lado cliente de 30 segundos a partir de `NetworkTree`, sin agregar trabajo de rollup en el backend.
|
||||
- Los APs pueden heredar coordenadas del sitio padre solo para visualización cuando faltan coordenadas explícitas.
|
||||
- Usa un mapa base local con estilo LibreQoS con bordes de país/estado, costas, lagos principales, ríos principales, áreas marinas, superposiciones sutiles de regiones físicas y contexto de autopistas principales a mayor zoom para orientación geográfica.
|
||||
- Site Map utiliza una capa local de carreteras derivada de Natural Earth para ayudar con la orientación, manteniendo el resto del mapa base discreto y operativo.
|
||||
- Los marcadores de sitios cercanos se agrupan y se expanden al acercar el mapa o seleccionar un grupo.
|
||||
- Los APs sin coordenadas explícitas se representan a través de su sitio padre y pueden desplegarse temporalmente alrededor del sitio seleccionado para inspección.
|
||||
- Los sitios visibles y sin agrupar muestran etiquetas al acercar el mapa, y el sitio seleccionado mantiene su etiqueta visible mientras se inspecciona.
|
||||
- Cuando el modo de redacción del navegador está habilitado, Site Map reemplaza los nombres de sitios mostrados por `[redacted]` sin modificar los datos reales de topología.
|
||||
- El encuadre inicial del mapa prioriza las coordenadas de los sitios para la vista inicial, usando coordenadas de AP solo cuando todavía no hay sitios mapeados.
|
||||
- Site Map usa una capa raster de OpenStreetMap alojada por Insight.
|
||||
- En modo oscuro, el mapa base se atenúa y se tiñe hacia una paleta azul/cian para mantener visibles carreteras y geografía sin el brillo del mapa claro.
|
||||
- Site Map depende del acceso saliente a `https://insight.libreqos.com` para el arranque inicial del mapa y la obtención de tiles.
|
||||
|
||||
### Flow Globe
|
||||
- Visualización geográfica de flujos basada en la geolocalización de endpoints.
|
||||
@@ -31,16 +56,38 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
|
||||
### ASN Analysis
|
||||
- Página operativa ASN en vivo que combina un ranking top-20 de ASN, gráfico de burbujas latencia-vs-tráfico, franja mínima de KPIs del ASN seleccionado, gráfico de tendencia ASN de 15 minutos y la sección integrada de Flow Evidence.
|
||||
- Soporta modos de ranking `Impact` y `Throughput`, manteniendo la evidencia de flujos ASN en la misma página.
|
||||
- La ruta heredada `ASN Explorer` ahora redirige aquí para conservar compatibilidad con marcadores antiguos.
|
||||
- Los marcadores antiguos de `ASN Explorer` siguen funcionando mediante redirección.
|
||||
- Resultados vacíos suelen indicar poco dato reciente, no necesariamente falla.
|
||||
|
||||
### Página de circuito
|
||||
- Las páginas de circuito combinan comportamiento de colas, throughput en vivo, RTT, retransmisiones y troubleshooting por flujo para un suscriptor/circuito individual.
|
||||
- Cuando los metadatos de la integración informan la velocidad Ethernet negociada del CPE, la fila `Max` puede mostrar una insignia de advertencia como `100M`; al pasar el cursor sobre la insignia se explica cuándo LibreQoS redujo automáticamente el shaping por debajo del plan solicitado para respetar ese límite del puerto, y al hacer clic en la insignia se abre la página de revisión Ethernet.
|
||||
- `Queue Dynamics` muestra el comportamiento del throughput y RTT del circuito a lo largo del tiempo, incluyendo un KPI de `Active Flows` basado en la misma ventana reciente usada por la tabla `Traffic Flows`.
|
||||
- `Top ASNs` resume los ASN recientes más activos del circuito a partir de esa misma ventana de flujos en vivo y ordena por tasa actual por defecto.
|
||||
- `Devices` muestra tablas de detalle por dispositivo y gráficos en vivo de throughput, retransmisiones y latencia.
|
||||
- `Queue Stats` muestra historial reciente de la cola del circuito, incluyendo backlog, delay, longitud de cola, tráfico, marcas ECN y drops.
|
||||
- Los gráficos de Queue Stats usan hover sincronizado para inspeccionar el mismo segundo en todos los gráficos de cola al mismo tiempo.
|
||||
- `Queue Tree` muestra la ruta ascendente de colas del circuito, incluyendo un resumen de ruta y contexto de throughput, retransmisiones y latencia para cada nodo aguas arriba.
|
||||
- `Traffic Flows` es una tabla operativa de flujos recientes, no una vista de historial a largo plazo.
|
||||
- `Traffic Flows` incluye paginación y un filtro `Hide Small Flows` para que los circuitos grandes y ocupados sigan siendo utilizables sin intentar renderizar cada fila.
|
||||
- La tasa actual de `Traffic Flows` se limita a valores plausibles y coherentes con el plan del circuito.
|
||||
- El texto largo en las columnas `Protocol`, `ASN` y `Country` se recorta con puntos suspensivos para mantener estable la altura de cada fila; el valor completo sigue disponible al pasar el cursor.
|
||||
- `Flow Sankey` enfatiza los flujos recientes más activos en lugar de todos los flujos retenidos más antiguos.
|
||||
|
||||
### Ethernet Caps
|
||||
- La página de revisión Ethernet es una tabla ligera para operadores con los circuitos reducidos automáticamente porque la velocidad Ethernet detectada quedó por debajo del plan solicitado.
|
||||
- Intencionalmente no aparece en la navegación principal; los operadores llegan a ella haciendo clic en las insignias de advertencia Ethernet de la página de circuito o de la tabla de circuitos adjuntos del árbol.
|
||||
- La página soporta búsqueda, filtro por tier (`10M`, `100M`, `1G+`) y paginación sobre los circuitos auto-capped.
|
||||
|
||||
### Árbol/ponderación de CPU
|
||||
- Muestra distribución de colas/circuitos por núcleo de CPU.
|
||||
- Ayuda a validar comportamiento de binpacking y balance de carga.
|
||||
- CPU Affinity comienza mostrando solo los CPUs de shaping, y los núcleos excluidos o solo-del-host pueden mostrarse cuando hace falta.
|
||||
|
||||
### Editor de Shaped Devices
|
||||
- Editor CRUD para `ShapedDevices.csv`.
|
||||
- Incluye paginación y filtros en versiones actuales.
|
||||
- Incluye paginación y filtros.
|
||||
- En el editor dedicado, las acciones de agregar, editar y eliminar se guardan de inmediato.
|
||||
|
||||
### Problemas urgentes
|
||||
- WebUI puede mostrar problemas operativos urgentes emitidos por servicios backend.
|
||||
@@ -60,6 +107,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
|
||||
- Se activa con el ícono de máscara en la barra superior.
|
||||
- Es redacción del lado cliente y se guarda en `localStorage` del navegador.
|
||||
- Ayuda a ocultar PII en capturas/demos.
|
||||
- Site Map reemplaza los nombres de sitios mostrados por `[redacted]` mientras el modo de redacción está activo.
|
||||
- No modifica `ShapedDevices.csv`, `network.json` ni datos backend.
|
||||
|
||||
## Comportamiento de vistas vacías
|
||||
|
||||
@@ -42,10 +42,13 @@ sudo /opt/libreqos/src/systemd_hotfix.sh install
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
El instalador del hotfix configura el repositorio APT de LibreQoS en `https://repo.libreqos.com`, instala el conjunto parchado de paquetes `systemd` de Noble y fija esos paquetes para futuras actualizaciones.
|
||||
|
||||
Después del reinicio, reanude la instalación:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget https://download.libreqos.com/{deb_url_v1_5}
|
||||
sudo apt install ./{deb_url_v1_5}
|
||||
```
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ StormGuard es el subsistema de ajuste adaptativo de colas de LibreQoS para event
|
||||
- Aplica ajustes acotados a límites de nodos configurados para proteger calidad bajo estrés.
|
||||
- Expone estado y depuración en WebUI.
|
||||
|
||||
Los cambios adaptativos de velocidad por sitio de StormGuard se guardan en la capa de overrides de StormGuard. No se escriben de vuelta en `network.json`.
|
||||
|
||||
## Configuración
|
||||
|
||||
StormGuard se configura en `/etc/lqos.conf` bajo `[stormguard]`.
|
||||
@@ -41,7 +43,12 @@ Si está probando, comience con `dry_run = true`.
|
||||
|
||||
## UI y depuración
|
||||
|
||||
- WebUI (Node Manager) incluye vistas de estado y depuración de StormGuard.
|
||||
- WebUI (Node Manager) incluye una pestaña dedicada de StormGuard además de las vistas de estado y depuración.
|
||||
- La pestaña del dashboard está pensada para responder "qué está haciendo StormGuard ahora mismo?" con:
|
||||
- tarjetas resumen para sitios observados, en cooldown y con cambios recientes
|
||||
- una lista de sitios que funciona tanto con un único sitio observado como con conjuntos más grandes
|
||||
- un panel de detalle por sitio seleccionado que explica límites actuales, últimas acciones y por qué StormGuard mantiene o cambia velocidades
|
||||
- un feed de actividad reciente para triage rápido del operador
|
||||
- La página de depuración muestra:
|
||||
- límites efectivos actuales
|
||||
- métricas de evaluación
|
||||
@@ -69,6 +76,7 @@ También verifique:
|
||||
- que los nombres en `targets` aún coincidan con `network.json`
|
||||
- que cambios de integración no hayan renombrado nodos clave
|
||||
- que los pisos mínimos sean razonables para su perfil de tráfico
|
||||
- que `network.json` siga reflejando sus velocidades planificadas/de origen si está investigando una reducción inesperada de StormGuard
|
||||
- que `log_file` (si se usa) sea escribible por el servicio
|
||||
|
||||
## Páginas relacionadas
|
||||
|
||||
@@ -32,11 +32,19 @@ TreeGuard evalúa utilización, frescura de RTT, guardrails de CPU y guardrails
|
||||
Comportamiento de alto nivel:
|
||||
|
||||
1. Bajo condiciones sostenidas de baja carga, TreeGuard puede cambiar una dirección de `cake` a `fq_codel`.
|
||||
2. Si sube la utilización, los guardrails RTT/QoO no son seguros o se cumplen condiciones de reversión, TreeGuard vuelve a `cake`.
|
||||
2. Si sube la utilización, los guardrails de QoO no son seguros o se cumplen condiciones de reversión, TreeGuard vuelve hacia la política SQM base del circuito.
|
||||
3. Las decisiones pueden ser independientes por dirección cuando `independent_directions = true`.
|
||||
|
||||
Esto crea un perfil dinámico en el que direcciones cargadas favorecen `cake diffserv4`, mientras direcciones de baja carga pueden usar `fq_codel` cuando las condiciones son seguras.
|
||||
|
||||
La política SQM base proviene de la intención del operador, no de defaults internos de TreeGuard. En la práctica, TreeGuard parte de la política efectiva configurada para cada circuito y solo persiste su propio overlay temporal cuando necesita diferir de esa base.
|
||||
|
||||
Regla importante de política base:
|
||||
|
||||
1. Si la política SQM base de una dirección es `cake`, TreeGuard puede cambiar temporalmente esa dirección a `fq_codel` y luego volver a la base.
|
||||
2. Si la política SQM base de una dirección es `fq_codel`, TreeGuard no conmuta esa dirección de circuito hacia `cake`.
|
||||
3. La virtualización de enlaces sigue disponible independientemente de la política SQM base del circuito.
|
||||
|
||||
## Configuración (`/etc/lqos.conf`)
|
||||
|
||||
La configuración de TreeGuard vive bajo `[treeguard]` y sus sub-secciones:
|
||||
@@ -55,6 +63,11 @@ enabled = true
|
||||
dry_run = false
|
||||
tick_seconds = 1
|
||||
|
||||
[treeguard.cpu]
|
||||
mode = "cpu_aware"
|
||||
cpu_high_pct = 75
|
||||
cpu_low_pct = 55
|
||||
|
||||
[treeguard.links]
|
||||
enabled = true
|
||||
all_nodes = true
|
||||
@@ -70,6 +83,12 @@ independent_directions = true
|
||||
enabled = true
|
||||
```
|
||||
|
||||
La virtualización de nodos en TreeGuard está pensada para ser basada en CPU por defecto. El
|
||||
tráfico, RTT y QoO siguen siendo señales importantes de seguridad y restauración, pero la nueva
|
||||
virtualización automática debe ocurrir cuando la presión de CPU indica que el ahorro de HTB vale
|
||||
la pena. Las instalaciones actualizadas desde defaults antiguos se migran silenciosamente de
|
||||
`traffic_rtt_only` a `cpu_aware`, con un aviso visible en logs/UI.
|
||||
|
||||
## Patrón de Despliegue Seguro
|
||||
|
||||
1. Revise la configuración de TreeGuard temprano en el despliegue en lugar de asumir comportamiento de colas fijo/manual.
|
||||
@@ -80,12 +99,54 @@ enabled = true
|
||||
|
||||
## Overrides y Notas Operativas
|
||||
|
||||
Cuando está habilitado y no está en dry-run, TreeGuard puede persistir decisiones en:
|
||||
Cuando está habilitado y no está en dry-run, TreeGuard puede persistir decisiones SQM de circuitos en:
|
||||
|
||||
- `lqos_overrides.treeguard.json`
|
||||
|
||||
TreeGuard está diseñado para no pelear con overrides del operador. Si existen overrides del operador para entidades enroladas, TreeGuard omite esas entidades y reporta advertencias.
|
||||
|
||||
Las decisiones de virtualización de nodos de TreeGuard son operaciones de Bakery solo en tiempo de
|
||||
ejecución. El scheduler no las materializa de vuelta en el `network.json` base, y tampoco se
|
||||
persisten como entradas TreeGuard `set_node_virtual` dentro de la entrada efectiva de shaping. En
|
||||
v1 son efímeras: un reinicio del daemon devuelve el árbol físico a la topología base definida por
|
||||
el operador hasta que TreeGuard vuelva a decidir.
|
||||
|
||||
Para verificación y depuración local en tiempo de ejecución, `liblqos_python` ahora expone tanto
|
||||
el estado actual de la operación del nodo TreeGuard como un snapshot del estado de ramas en tiempo
|
||||
de ejecución de Bakery. Ese snapshot de ramas es la vista autoritativa del plano de control sobre
|
||||
qué rama retenida está activa para un nodo, y es más confiable que inferir cambios de parentaje
|
||||
solo desde la salida de `tc` en casos no top-level.
|
||||
El flujo local de confianza también usa ahora un fixture sintético de Bakery TreeGuard más grande
|
||||
para pruebas de escala: 8 nodos top-level, 3 niveles de profundidad y 1.000 circuitos conectados
|
||||
solo en el nivel más bajo. Cuando la verificación con tráfico está habilitada, el runtime verifier
|
||||
ahora usa por defecto 10 circuitos rastreados por cada caso exitoso en lugar del smoke test menor
|
||||
anterior.
|
||||
|
||||
Las decisiones SQM por circuito de TreeGuard también son overrides de tiempo de ejecución. El scheduler no materializa los cambios SQM propiedad de TreeGuard de vuelta en el `ShapedDevices.csv` base, por lo que limpiar TreeGuard no reescribe permanentemente la política SQM definida por el operador.
|
||||
|
||||
TreeGuard también se niega a gestionar nodos que ya estén marcados con `"virtual": true` en el `network.json` base. Si existen overrides legados de TreeGuard para esos nodos, TreeGuard limpia ese estado legado y vuelve a respetar la definición base de la topología.
|
||||
|
||||
Para la gestión SQM por circuito, TreeGuard trata los valores duplicados de `device_id` como colisiones de identidad inseguras. Si el mismo `device_id` aparece en más de un circuito dentro de `ShapedDevices.csv`, TreeGuard omite esos circuitos afectados y limpia cualquier override SQM de TreeGuard asociado a esos `device_id` duplicados.
|
||||
|
||||
Si la telemetría RTT no está disponible temporalmente después de un reinicio, TreeGuard no trata la ausencia de RTT por sí sola como evidencia para revertir direcciones en `fq_codel`. Siguen aplicando otros guardrails como utilización, QoO y presión de CPU.
|
||||
|
||||
TreeGuard también aplica un presupuesto global conservador de cambios SQM por tick. En poblaciones muy grandes de circuitos enrolados, los cambios SQM excedentes se difieren a ticks posteriores en lugar de saturar Bakery en una sola pasada.
|
||||
|
||||
Para escalar mejor, TreeGuard ya no reconstruye la membresía de circuitos desde `ShapedDevices.csv` en cada tick de circuitos. Ahora mantiene un inventario por circuito en caché derivado de `ShapedDevices.csv`, lee la telemetría viva por circuito desde el snapshot compartido de rollup por circuito actualizado una vez por segundo, y reparte las evaluaciones SQM grandes con `all_circuits = true` a lo largo de múltiples ticks en lugar de reescanear todos los circuitos enrolados cada segundo.
|
||||
|
||||
En la práctica esto significa:
|
||||
|
||||
1. La virtualización de enlaces sigue la cadencia normal de ticks de TreeGuard.
|
||||
2. La evaluación SQM de circuitos para enrolamientos pequeños sigue completándose rápidamente.
|
||||
3. Los enrolamientos muy grandes con `all_circuits` se recorren de forma incremental a lo largo de varios ticks, con un objetivo de barrido completo de alrededor de 15 segundos en vez de intentar un escaneo completo por segundo.
|
||||
4. La virtualización de nodos de TreeGuard ahora usa rutas vivas de planificación/aplicación en Bakery en lugar de forzar una recarga completa de LibreQoS o de Bakery.
|
||||
5. La virtualización runtime soportada de nodos top-level ahora usa un plan de rebalanceo/migración en Bakery que puede promover sitios hijos y circuitos directos entre raíces de cola, preservando la jerarquía lógica para reportes.
|
||||
|
||||
La actividad reciente de TreeGuard está disponible en dos lugares:
|
||||
|
||||
- Las vistas de estado/actividad de TreeGuard en la WebUI.
|
||||
- El journal de `lqosd`, donde TreeGuard ahora registra cada evento de actividad para que recargas, limpieza de overrides, cambios de SQM y fallos se puedan diagnosticar sin inspeccionar websockets.
|
||||
|
||||
## Páginas Relacionadas
|
||||
|
||||
- [HTB + fq_codel + CAKE: Comportamiento Detallado de Colas](htb_fq_codel_cake-es.md)
|
||||
|
||||
@@ -46,14 +46,17 @@ Incluya también:
|
||||
|
||||
### La contraseña de usuario no funciona
|
||||
|
||||
Elimine el archivo de usuarios:
|
||||
Los builds actuales:
|
||||
- migran automáticamente archivos de autenticación antiguos
|
||||
- redirigen `/login.html` a `/first-run.html` cuando no existen usuarios
|
||||
|
||||
```bash
|
||||
sudo rm /opt/libreqos/src/lqusers.toml
|
||||
sudo systemctl restart lqosd lqos_scheduler
|
||||
sudo systemctl restart lqosd
|
||||
```
|
||||
|
||||
Luego abra: `IP_CAJA:9123/index.html`.
|
||||
Si el usuario/contraseña correctos siguen fallando, pruebe primero con ese reinicio.
|
||||
|
||||
Elimine `lqusers.toml` solo si desea reiniciar el acceso o si el archivo está corrupto y no se puede reparar. Después de eliminarlo, reinicie `lqosd` y abra `IP_CAJA:9123/login.html`; la WebUI debería redirigirlo automáticamente al flujo de primer inicio.
|
||||
|
||||
### No hay WebUI en x.x.x.x:9123
|
||||
|
||||
@@ -103,6 +106,8 @@ sudo RUST_LOG=info /opt/libreqos/src/bin/lqosd
|
||||
sudo journalctl -u lqos_scheduler --since "1 day ago" --no-pager > lqos_sched_log.txt
|
||||
```
|
||||
|
||||
Si el scheduler falla inmediatamente después de un reinicio con un mensaje como `Socket (typically /run/lqos/bus) not found`, eso indica que `lqosd` todavía no había terminado de enlazar el bus local. Los builds actuales esperan brevemente la disponibilidad del bus al arrancar el scheduler en lugar de abortar de inmediato, por lo que ya no deberían aparecer panics repetidos de arranque tras un reinicio.
|
||||
|
||||
### El estado del scheduler en WebUI aparece no saludable
|
||||
|
||||
Versiones recientes muestran estado/readiness del scheduler en WebUI.
|
||||
@@ -214,6 +219,10 @@ WebUI muestra códigos legibles por máquina para triage rápido.
|
||||
|---|---|---|---|
|
||||
| `MAPPED_CIRCUIT_LIMIT` | Bakery está forzando límite de circuitos mapeados. | Estado de licencia Insight y `journalctl -u lqosd` con requested/allowed/dropped. | Reducir circuitos mapeados o actualizar licencia/límites. |
|
||||
| `TC_U16_OVERFLOW` | IDs minor de clases/colas excedieron rango u16 de tc en una cola CPU. | `journalctl -u lqos_scheduler -u lqosd`, profundidad topológica y distribución por colas. | Aumentar paralelismo de colas y/o simplificar/rebalancear jerarquía. |
|
||||
| `TC_QDISC_CAPACITY` | Los qdisc autoasignados planificados exceden el presupuesto seguro por interfaz o el preflight conservador de seguridad de memoria de Bakery antes de aplicar. | Conteos estimados por interfaz, desglose por tipo de qdisc y campos de memoria en el contexto del urgent issue, `journalctl -u lqos_scheduler -u lqosd`, configuración `on_a_stick` y `monitor_only`. | Reducir la carga planificada de qdisc para esta ejecución (por ejemplo menos circuitos/dispositivos en la forma de prueba) antes de reintentar; no confiar en una aplicación parcial. |
|
||||
| `BAKERY_MEMORY_GUARD` | Un full reload chunked de Bakery fue detenido a mitad de aplicación porque la memoria disponible del host cayó por debajo del piso de seguridad. | `journalctl -u lqosd`, memoria disponible/total en el contexto del urgent issue y progreso reciente de aplicación de Bakery. | Tratar la ejecución como fallida, reducir presión de memoria o huella de colas y reintentar solo cuando el host esté estable. |
|
||||
| `XDP_IP_MAPPING_CAPACITY` | Los mapeos IP requeridos exceden la capacidad actual del mapa XDP en el kernel. | Forma de `ShapedDevices.csv`, mezcla IPv4/IPv6, supuesto de un dispositivo frente a varios dispositivos, `journalctl -u lqos_scheduler -u lqosd`. | Reducir mapeos requeridos de inmediato (por ejemplo menos dispositivos o prueba IPv4-only), o aumentar la capacidad del mapa del kernel en un cambio coordinado. |
|
||||
| `XDP_IP_MAPPING_APPLY_FAILED` | Uno o más inserts de mapeo IP fallaron durante la aplicación. | `journalctl -u lqos_scheduler -u lqosd` para ejemplos resumidos y conteos de fallo. | Corregir la causa del fallo y volver a ejecutar; no confiar en shaping parcial. |
|
||||
|
||||
Patrón operativo:
|
||||
|
||||
|
||||
@@ -39,10 +39,13 @@ sudo /opt/libreqos/src/systemd_hotfix.sh install
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
El instalador del hotfix configura el repositorio APT de LibreQoS en `https://repo.libreqos.com`, instala el conjunto parchado de paquetes `systemd` de Noble y fija esos paquetes para futuras actualizaciones.
|
||||
|
||||
Después del reinicio, reanuda la actualización y reinicia los servicios:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget https://download.libreqos.com/{deb_url_v1_5}
|
||||
sudo apt install ./{deb_url_v1_5}
|
||||
sudo systemctl restart lqosd lqos_scheduler
|
||||
```
|
||||
|
||||
@@ -34,6 +34,8 @@ sudo systemctl enable lqos_api
|
||||
sudo systemctl start lqos_api
|
||||
```
|
||||
|
||||
The shipped `lqos_api.service.example` waits for `network-online.target`, so the API does not start before DNS and the default route are ready.
|
||||
|
||||
Update only the API binary:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -33,6 +33,7 @@ flowchart LR
|
||||
- lqos_scheduler performs continuous refreshes of LibreQoS' shapers, including pulling from any enabled CRM Integrations (UISP, Splynx, Netzur).
|
||||
- Actions:
|
||||
- On start: Run a full setup of queues
|
||||
- Current builds wait briefly for `lqosd` to finish binding the local bus before the first scheduler run.
|
||||
- Every X minutes: Update queues, pulling new configuration from CRM integration, if enabled.
|
||||
- The default minute interval is 30, so the refresh occurs every 30 minutes by default.
|
||||
- The minute interval is adjustable with the setting `queue_refresh_interval_mins` in `/etc/lqos.conf`.
|
||||
@@ -84,6 +85,8 @@ To confirm that lqos_scheduler (scheduler.py) is able to work correctly, run:
|
||||
sudo python3 scheduler.py
|
||||
```
|
||||
|
||||
If an integration fails during a scheduler run, current builds keep the scheduler alive, surface a shortened output preview in scheduler status/error reporting, and save the full captured output to a timestamped `/tmp/lqos_scheduler_<integration>_*.log` file.
|
||||
|
||||
Once you have any errors eliminated, restart lqos_scheduler with
|
||||
|
||||
```shell
|
||||
|
||||
@@ -81,6 +81,20 @@ If integration mode is enabled, integration refresh cycles typically own `Shaped
|
||||
- Use WebUI/manual edits for short operational adjustments only.
|
||||
- Put permanent changes in your integration system, integration overrides, or declared external source of truth workflow.
|
||||
|
||||
#### IP range allow/ignore behavior for integrations
|
||||
|
||||
The `[ip_ranges]` section is also used when integrations generate subscriber/device shaping data.
|
||||
|
||||
- `allow_subnets` defines the address space LibreQoS should consider shapeable.
|
||||
- `ignore_subnets` removes matching addresses from generated subscriber devices even if they are otherwise present in the source CRM/NMS.
|
||||
- Current shared integration-output pruning uses `ignore_subnets` to exclude generated subscriber/device rows, but does not newly require all imported integration IPs to be inside `allow_subnets` just to survive generation.
|
||||
- If an imported device is left with no remaining non-ignored IPs after `ignore_subnets` is applied, that device is omitted from generated `ShapedDevices.csv`.
|
||||
- If an imported circuit is left with no remaining shaped devices, that circuit is omitted from generated `ShapedDevices.csv`.
|
||||
|
||||
This can be used to exclude entire subscriber populations from LibreQoS shaping and Insight-reported shaped-device inventory. For example, some operators shape wireless subscribers in LibreQoS but exclude FTTH subscribers whose ONTs already enforce service rates.
|
||||
|
||||
Use this carefully: `ignore_subnets` is broader than a billing-only toggle. The same setting also affects other LibreQoS/Insight IP-policy handling.
|
||||
|
||||
#### CRM/NMS Integrations
|
||||
|
||||
Learn more about [configuring integrations here](integrations.md).
|
||||
@@ -125,13 +139,16 @@ How overrides apply:
|
||||
- `lqos_scheduler` applies overrides during refresh cycles.
|
||||
- persistent devices are merged into `ShapedDevices.csv`.
|
||||
- circuit/device/network adjustments are applied on top of imported/manual data.
|
||||
- operator-owned site bandwidth overrides prefer `node_id` when present and fall back to legacy name-only matching.
|
||||
- tree-page `Operator Override` writes to the operator override layer in `lqos_overrides.json`, not to legacy integration bandwidth CSV files.
|
||||
- automated runtime layers such as StormGuard and TreeGuard remain separate from the operator layer and are not written back into operator-authored source files.
|
||||
|
||||
### Network Hierarchy
|
||||
#### Network.json
|
||||
|
||||
Network.json allows ISP operators to define a Hierarchical Network Topology, or Flat Network Topology.
|
||||
|
||||
Each topology node may optionally include an `"id"` field. This is intended to carry a stable node identifier from the source CRM/NMS when one exists. LibreQoS currently still matches hierarchy and overrides by node name, not by this ID, so `"id"` is metadata for now.
|
||||
Each topology node may optionally include an `"id"` field. This is intended to carry a stable node identifier from the source CRM/NMS when one exists. Current builds prefer this ID when matching operator-owned site bandwidth overrides, while still supporting legacy name-only matching as a fallback.
|
||||
|
||||
Recommended format:
|
||||
|
||||
@@ -151,6 +168,15 @@ Notes:
|
||||
- Generated LibreQoS-only nodes may use stable generated IDs such as `libreqos:generated:uisp:site:orphans`.
|
||||
- Existing integration-specific metadata fields such as `uisp_site` and `uisp_device` may also appear alongside the generic `id` field.
|
||||
|
||||
#### Queue mode (`shape` / `observe`)
|
||||
|
||||
LibreQoS currently uses `queue_mode` in the `[queues]` section to control whether the shaping tree is active:
|
||||
|
||||
- `queue_mode = "shape"`: normal shaping mode
|
||||
- `queue_mode = "observe"`: remove the subscriber shaping tree for a true baseline while keeping the root MQ in place
|
||||
|
||||
The older `monitor_only` setting is retained as a compatibility alias in some configs and serialized output, but `queue_mode` is the current operator-facing setting and documentation term.
|
||||
|
||||
If you plan to use the built-in UISP, Splynx, or Netzur integrations, you do not need to create a network.json file quite yet.
|
||||
If you plan to use the built-in UISP integration, it will create this automatically on its first run (assuming network.json is not already present).
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ Notes:
|
||||
|
||||
After install, sign in to WebUI at `http://your_shaper_ip:9123`.
|
||||
|
||||
If no WebUI users exist yet, current builds redirect to first-run setup automatically.
|
||||
|
||||
For most operators:
|
||||
1. Choose your operating mode: [Operating Modes and Source of Truth](operating-modes.md)
|
||||
2. Configure integration settings in WebUI: [CRM/NMS Integrations](integrations.md)
|
||||
@@ -25,13 +27,25 @@ For most operators:
|
||||
|
||||
Most day-to-day LibreQoS configuration is handled in WebUI (`http://your_shaper_ip:9123/config_general.html`).
|
||||
|
||||
Current builds use shared configuration panels on the General, RTT, Queues, TreeGuard, Network Mode, Integration Defaults, Network Layout, Insight, provider-specific integration pages, IP Ranges, Flow Tracking, and Shaped Devices pages so node settings and operational tuning follow the same surface styling as the rest of Node Manager. The TreeGuard page starts with a live behavior summary rather than a permanent warning banner.
|
||||
|
||||
### Where in WebUI
|
||||
|
||||
- General settings: `Configuration -> General`
|
||||
- Integration settings: `Configuration -> Integrations`
|
||||
- Network layout editor: `Configuration -> Network Layout`
|
||||
- Shaped devices editor: `Configuration -> Shaped Devices`
|
||||
- Runtime operational validation: `WebUI (Node Manager)` pages (dashboard/tree/flow/scheduler)
|
||||
|
||||
When an integration is enabled and acting as the source of truth, the `Network Layout` and `Shaped Devices` editors remain viewable but become read-only in WebUI.
|
||||
|
||||
Topology note:
|
||||
- `network.json` node names must be globally unique across the whole tree. Duplicate node names now fail validation and are not accepted by the WebUI save path or `LibreQoS.py`.
|
||||
- When a node exposes a stable `id`, current builds prefer that ID for operator-owned site bandwidth overrides while keeping legacy name-only matching as a fallback.
|
||||
|
||||
Queue-mode note:
|
||||
- Current builds use `queue_mode` with `shape` and `observe` values. Older `monitor_only` wording is a compatibility alias rather than the primary operator-facing setting.
|
||||
|
||||
## Operating Modes and Source of Truth
|
||||
|
||||
Read this first before production changes:
|
||||
|
||||
@@ -83,6 +83,8 @@ sudo cp /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.se
|
||||
sudo cp /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service
|
||||
```
|
||||
|
||||
These templates wait for `network-online.target`, so `lqosd` and `lqos_scheduler` do not start before DNS and the default route are ready.
|
||||
|
||||
Finally, run
|
||||
|
||||
```shell
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
First, set the relevant parameters for Sonar (sonar_api_key, sonar_api_url, etc.) in `/etc/lqos.conf`.
|
||||
|
||||
Current behavior notes:
|
||||
- `sonar_api_url` may be either the Sonar base URL or the full GraphQL endpoint. LibreQoS normalizes it to `/api/graphql` automatically.
|
||||
- Current builds page through Sonar GraphQL results instead of relying on a small first-page sample.
|
||||
- Paginated Sonar GraphQL requests now use a split connect/read timeout and retry transient read timeouts before failing the import.
|
||||
- Emitted Sonar identities are namespaced (for example `sonar:account:<id>` and `sonar:device:<id>`) so they remain stable across overrides and downstream tooling.
|
||||
- Account device discovery now preserves inventory-item IP handling and also imports Radius account IP assignments when they exist. Inventory-backed MACs are still used for AP mapping; Radius-only IPs are added as supplemental shaping devices and overlapping subnets are de-duplicated.
|
||||
- Sonar `child_accounts` are also imported when they expose their own service and usable IP data. If a child account lacks its own address, LibreQoS falls back to the parent account address so the child can still be emitted as its own circuit.
|
||||
- Sonar settings now support ISP-specific recurring-service fallback rates plus a recurring-service exclusion list. LibreQoS still prefers active `DATA` services first; recurring mappings are only used when an account has no usable `DATA` service.
|
||||
- If Sonar returns non-JSON content or GraphQL errors, the integration now raises a more specific error message showing the endpoint and a short response preview.
|
||||
|
||||
To test the Sonar Integration, use
|
||||
|
||||
```shell
|
||||
|
||||
@@ -130,12 +130,16 @@ If sync looks successful but data is incomplete, recheck API permissions and str
|
||||
|
||||
You can also modify the the file `integrationSplynxBandwidths.csv` to override the default bandwidths for each Node (Site, AP).
|
||||
|
||||
Tree-page `Operator Override` edits are separate from this legacy Splynx file. Current builds write those operator-owned node rate changes to `lqos_overrides.json` and do not rewrite `integrationSplynxBandwidths.csv`.
|
||||
|
||||
A template is available in the `/opt/libreqos/src` folder. To utilize the template, copy the file `integrationSplynxBandwidths.template.csv` (removing the `.template` part of the filename) and set the appropriate information inside each file. For example, if you want to change the set bandwidth for a site, you would do:
|
||||
```
|
||||
sudo cp /opt/libreqos/src/integrationSplynxBandwidths.template.csv /opt/libreqos/src/integrationSplynxBandwidths.csv
|
||||
```
|
||||
And edit the CSV using LibreOffice or your preferred CSV editor.
|
||||
|
||||
To avoid conflicting sources of truth, prefer one durable override path per node: either the legacy Splynx CSV workflow or the operator override layer.
|
||||
|
||||
|
||||
## Related Pages
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ If a `network.json` file exists, it is only overwritten when `always_overwrite_n
|
||||
|
||||
ShapedDevices.csv will be overwritten every time the UISP integration is run.
|
||||
|
||||
When UISP client sites share the same name, LibreQoS now tries to disambiguate the generated circuit/site display names with a human-friendly suffix such as the first street-address segment, falling back to service name and then a short ID only when needed. Stable circuit identity still comes from the UISP site/service ID, not the display name.
|
||||
|
||||
For integration-driven deployments, keep `always_overwrite_network_json = true` so topology stays aligned with UISP on each refresh cycle.
|
||||
|
||||
You have the option to run `uisp_integration` automatically on boot and every X minutes (set by the parameter `queue_refresh_interval_mins`), which is highly recommended. This can be enabled by setting ```enable_uisp = true``` in `/etc/lqos.conf`. Once set, run `sudo systemctl restart lqos_scheduler`.
|
||||
@@ -183,6 +185,8 @@ You can also modify the following files to more accurately reflect your network:
|
||||
- integrationUISPbandwidths.csv
|
||||
- integrationUISProutes.csv
|
||||
|
||||
Tree-page `Operator Override` edits are separate from these legacy UISP files. Current builds write those operator-owned node rate changes to `lqos_overrides.json` and do not rewrite `integrationUISPbandwidths.csv`.
|
||||
|
||||
Each of the files above have templates available in the `/opt/libreqos/src` folder. If you don't find them there, you can navigate [here](https://github.com/LibreQoE/LibreQoS/tree/develop/src). To utilize the template, copy the file (removing the `.template` part of the filename) and set the appropriate information inside each file.
|
||||
For example, if you want to change the set bandwidth for a site, you would do:
|
||||
```
|
||||
@@ -190,6 +194,8 @@ sudo cp /opt/libreqos/src/integrationUISPbandwidths.template.csv /opt/libreqos/s
|
||||
```
|
||||
And edit the CSV using LibreOffice or your preferred CSV editor.
|
||||
|
||||
To avoid conflicting sources of truth, prefer one durable override path per node: either the legacy UISP CSV workflow or the operator override layer.
|
||||
|
||||
#### UISP Route Overrides
|
||||
|
||||
The default cost between nodes is typically 10. The integration creates a dot graph file `/opt/libreqos/src/graph.dot` which can be rendered using [Graphviz](https://dreampuf.github.io/GraphvizOnline/). This renders a map with the associated costs of all links.
|
||||
|
||||
@@ -53,7 +53,12 @@ When integrations are enabled:
|
||||
|
||||
## Topology Node ID Support
|
||||
|
||||
LibreQoS supports an optional generic `"id"` field on `network.json` nodes. This field is intended to carry stable node identifiers from the integration source where possible. In the current release, the field is informational and is not yet the authoritative match key for shaping or overrides.
|
||||
LibreQoS supports an optional generic `"id"` field on `network.json` nodes. This field is intended to carry stable node identifiers from the integration source where possible.
|
||||
|
||||
Current behavior:
|
||||
- node IDs are the preferred match key for operator-owned site bandwidth overrides and tree-page node override editing when an ID is present
|
||||
- legacy name-only matching is still supported as a fallback for older data
|
||||
- topology names still need to remain globally unique in `network.json`
|
||||
|
||||
| Integration | `network.json` node ID support | Notes |
|
||||
|---|---|---|
|
||||
|
||||
@@ -36,6 +36,7 @@ In production terms:
|
||||
- `XDP`/`eBPF` and lookup maps decide packet identity and CPU path.
|
||||
- Linux traffic control (`tc`) enforces queueing policy.
|
||||
- Bakery manages update deltas and reload boundaries.
|
||||
- Queue mode (`shape` vs `observe`) controls whether the subscriber shaping tree is active or intentionally removed for baseline measurement.
|
||||
|
||||
## 2) Runtime Invariants
|
||||
|
||||
@@ -48,8 +49,23 @@ These invariants are useful for reasoning about whether backend behavior is heal
|
||||
| Data-plane mapping is stable between XDP and `tc` | Mis-mapped packets cause wrong queue assignment | Unexpected class counters, mis-accounted traffic | Compare expected class IDs vs observed `tc -s` class counters |
|
||||
| Control-plane changes stay within incremental-safe bounds | Reduces disruption from full-tree rebuilds | Frequent packet-impacting reload windows | Review change patterns: structural vs speed/mapping updates |
|
||||
| Queue count stays within CPU/RAM budget | Leaf qdisc scale directly impacts resources | Memory growth, reload slowdowns, jitter under churn | Track queue count, RAM headroom, and update cadence |
|
||||
| Queue mode and dataplane mappings are sequenced consistently | Packets must not be steered toward removed queue state | Brief outages, stale class targets, mis-accounted traffic | Check queue mode, IP mapping lifecycle, and live `tc` state together |
|
||||
|
||||
## 3) End-to-End Packet and Control Path
|
||||
## 3) Runtime Authority and Configuration Model
|
||||
|
||||
LibreQoS has both on-disk config files and a long-running runtime daemon, but they are not the same authority at every moment.
|
||||
|
||||
1. `lqosd` is the runtime control-plane authority while it is running.
|
||||
2. UI/config API updates go through `lqosd`, which updates in-memory state and then drives apply/reload behavior.
|
||||
3. Manual edits to `/etc/lqos.conf` are operator-managed source inputs, but they do not automatically become active runtime state until the daemon reload path consumes them.
|
||||
|
||||
Operational takeaway:
|
||||
|
||||
- treat the UI/config API path as the authoritative live-update workflow
|
||||
- treat direct file edits as a separate operator action that still needs a runtime reload/apply boundary
|
||||
- do not assume “file changed” and “runtime desired state changed” are equivalent at the same instant
|
||||
|
||||
## 4) End-to-End Packet and Control Path
|
||||
|
||||
```{mermaid}
|
||||
flowchart LR
|
||||
@@ -76,9 +92,9 @@ flowchart LR
|
||||
N -. updates queue state .-> H
|
||||
```
|
||||
|
||||
## 4) Data Plane Design
|
||||
## 5) Data Plane Design
|
||||
|
||||
### 4.1 XDP and classification pipeline
|
||||
### 5.1 XDP and classification pipeline
|
||||
|
||||
LibreQoS performs early packet work in XDP where possible:
|
||||
|
||||
@@ -93,11 +109,11 @@ A key optimization direction has been reducing repeated lookups:
|
||||
- avoid duplicate work between XDP and `tc` when metadata can be passed forward
|
||||
|
||||
|
||||
### 4.2 Why `cpumap` is central
|
||||
### 5.2 Why `cpumap` is central
|
||||
|
||||
`cpumap` is used to spread work across cores so shaping does not bottleneck on a single queue path. This is a major part of scaling from "works" to "works at ISP traffic levels".
|
||||
|
||||
### 4.3 Cache, generation, and lock-pressure reduction
|
||||
### 5.3 Cache, generation, and lock-pressure reduction
|
||||
|
||||
The high-level progression described in development notes/devblogs:
|
||||
|
||||
@@ -107,8 +123,19 @@ The high-level progression described in development notes/devblogs:
|
||||
|
||||
Operationally, this helps stabilize latency and CPU under frequent updates.
|
||||
|
||||
### 5.4 Mapping state is part of the data-path contract
|
||||
|
||||
## 5) Queue Hierarchy: `mq` -> `HTB` -> leaf qdisc
|
||||
Queue classes and IP mappings are related but not identical backend state.
|
||||
|
||||
1. The shaping tree defines where packets can land in `tc`.
|
||||
2. IP mappings define which circuit/class packets are currently steered toward.
|
||||
3. During ordinary `shape -> shape` updates, LibreQoS tries to preserve stable mappings and stable queue handles where safe.
|
||||
4. During disruptive transitions, especially `observe <-> shape`, LibreQoS can intentionally clear and later republish mappings so packets are not pointed at queue state that no longer exists.
|
||||
|
||||
This sequencing matters as much as the queue commands themselves. A healthy backend design must reason about queue-tree changes and mapping changes together.
|
||||
|
||||
|
||||
## 6) Queue Hierarchy: `mq` -> `HTB` -> leaf qdisc
|
||||
|
||||
LibreQoS queueing is intentionally layered:
|
||||
|
||||
@@ -134,7 +161,7 @@ flowchart TD
|
||||
I --> J
|
||||
```
|
||||
|
||||
### 5.1 HTB internals that matter in production
|
||||
### 6.1 HTB internals that matter in production
|
||||
|
||||
Important mechanics:
|
||||
|
||||
@@ -150,16 +177,16 @@ Why operators care:
|
||||
- HTB is the rate envelope; leaf qdiscs are not a drop-in replacement for HTB policy
|
||||
|
||||
|
||||
## 6) AQM in LibreQoS: `fq_codel` and `CAKE`
|
||||
## 7) AQM in LibreQoS: `fq_codel` and `CAKE`
|
||||
|
||||
### 6.1 Responsibility split
|
||||
### 7.1 Responsibility split
|
||||
|
||||
Practical split:
|
||||
|
||||
1. HTB: hierarchical bandwidth policy and limits
|
||||
2. `fq_codel`/`CAKE`: queue fairness and delay control within that policy
|
||||
|
||||
### 6.2 Why AQM still helps even below line-rate saturation
|
||||
### 7.2 Why AQM still helps even below line-rate saturation
|
||||
|
||||
Even when a link is not saturated, fq_codel and CAKE still affect packet behavior. The drop logic (CoDel/BLUE/COBALT) usually stays idle, but the fair-queueing scheduler continues to interleave flows, preventing bursts from one flow from monopolizing the serialization point of the link. This keeps latency-sensitive traffic responsive.
|
||||
|
||||
@@ -189,7 +216,7 @@ Even without sustained congestion:
|
||||
|
||||
So even when the AQM drop logic is mostly idle, the fair-queueing part of SQM is still doing useful work by controlling how packets reach the wire.
|
||||
|
||||
### 6.3 CAKE vs fq_codel in LibreQoS terms
|
||||
### 7.3 CAKE vs fq_codel in LibreQoS terms
|
||||
|
||||
General pattern:
|
||||
|
||||
@@ -202,7 +229,7 @@ Resource reality:
|
||||
- both are flow-aware and keep state
|
||||
- CAKE can have higher memory/CPU footprint in large queue populations
|
||||
|
||||
### 6.4 When below-line-rate gains may be limited
|
||||
### 7.4 When below-line-rate gains may be limited
|
||||
|
||||
Lower latency under mixed load is common, but not guaranteed in every scenario.
|
||||
|
||||
@@ -218,7 +245,7 @@ Operator takeaway:
|
||||
- Treat AQM gains as a result of queue dynamics and contention control, then validate empirically on your own traffic mix.
|
||||
|
||||
|
||||
## 7) Bakery and Reload Behavior
|
||||
## 8) Bakery and Reload Behavior
|
||||
|
||||
Bakery exists to avoid unnecessary queue rebuilds and reduce reload penalties.
|
||||
|
||||
@@ -227,10 +254,10 @@ High-level flow:
|
||||
1. Build desired state.
|
||||
2. Diff desired vs active state.
|
||||
3. Apply smallest safe delta.
|
||||
4. Trigger full reload only when update type crosses live-mutation limits.
|
||||
4. Trigger full reload when a change is outside live-mutation support, or when runtime verification/drift detection marks incremental topology mutation unsafe.
|
||||
|
||||
|
||||
### 7.1 Lazy queueing and expiration
|
||||
### 8.1 Lazy queueing and expiration
|
||||
|
||||
Key controls:
|
||||
|
||||
@@ -242,42 +269,132 @@ Practical effect:
|
||||
- reduced memory overhead for dormant endpoints
|
||||
- lower churn for large but partially active subscriber populations
|
||||
|
||||
### 7.2 Incremental vs reload boundary
|
||||
### 8.2 Incremental vs reload boundary
|
||||
|
||||
| Change type | Usually incremental-safe | Often requires full reload | Why |
|
||||
|---|---|---|---|
|
||||
| Circuit IP-only change | Yes | No | Mapping update can often be applied without tree rebuild |
|
||||
| Circuit/site speed change (subset) | Yes | Sometimes | Depends on structural impact and available class handles |
|
||||
| Bulk all-circuit changes | Sometimes | Often | Scale and transaction/cardinality limits |
|
||||
| Topology re-parent/restructure | Rarely | Yes | HTB subtree mutation constraints |
|
||||
| Add/remove circuits | Yes (small/moderate) | Sometimes | Handle availability and diff correctness boundaries |
|
||||
| Circuit IP-only change | Yes | No | Mapping updates can usually be applied without rebuilding the queue tree |
|
||||
| Circuit SQM-only change | Yes | No | Leaf qdisc kind/parameter changes can usually be applied live |
|
||||
| Circuit/site speed change (subset) | Yes | Sometimes | Depends on structural impact, queue-count pressure, and available class handles |
|
||||
| Ordinary circuit parent move | Yes | Sometimes | Bakery uses staged live migration for common active parent/class moves, including qdisc-handle rotation and final-state verification, but it still escalates to reload if the migration cannot be applied or verified safely |
|
||||
| TreeGuard runtime node virtualization (supported subtree/top-level rebalance path) | Yes | Sometimes | Bakery can apply supported runtime virtualization live, but deferred cleanup, live-state verification failures, or accumulated dirty runtime subtrees can mark `reload required` and freeze further incremental topology mutation until a full reload |
|
||||
| Bulk all-circuit changes | Sometimes | Often | Scale and transaction/cardinality limits still matter, even with better incremental behavior |
|
||||
| Site add/remove or broader structural topology change | Rarely | Yes | HTB subtree mutation constraints remain much stricter at site/topology level than at per-circuit level |
|
||||
| Add/remove circuits | Yes (small/moderate) | Sometimes | Handle availability, tree size, and diff correctness boundaries |
|
||||
|
||||
This table reflects Bakery design behavior and Linux `tc` mutation constraints discussed in the devblog material.
|
||||
|
||||
```{mermaid}
|
||||
flowchart TD
|
||||
A[Config or integration change arrives] --> B[Build desired state and compute diff]
|
||||
A[Config or runtime change arrives] --> B[Build desired or runtime target state]
|
||||
B --> C{Any effective state change?}
|
||||
C -->|No| D[No-op]
|
||||
C -->|Yes| E{Structural hierarchy change?}
|
||||
E -->|Yes| F[Controlled full reload]
|
||||
E -->|No| G{Within incremental-safe limits?}
|
||||
G -->|Yes| H[Apply incremental tc updates]
|
||||
G -->|No| F
|
||||
H --> I[Verify class/qdisc state and counters]
|
||||
F --> I
|
||||
C -->|Yes| E{Supported live mutation path?}
|
||||
E -->|No| F[Controlled full reload]
|
||||
E -->|Yes| G[Apply staged incremental/runtime mutation]
|
||||
G --> H{Live verification and cleanup safe?}
|
||||
H -->|Yes| I[Keep incremental state authoritative]
|
||||
H -->|No| J[Mark reload required and freeze further incremental topology mutation]
|
||||
J --> F
|
||||
F --> K[Re-establish single authoritative queue model]
|
||||
```
|
||||
|
||||
### 7.3 Reload boundary quick rules
|
||||
### 8.3 Queue modes and transition semantics
|
||||
|
||||
LibreQoS currently has two explicit queue modes:
|
||||
|
||||
1. `shape`
|
||||
- normal shaping mode
|
||||
- root `mq` present
|
||||
- HTB hierarchy present
|
||||
- leaf qdiscs present
|
||||
- per-circuit IP mappings present
|
||||
2. `observe`
|
||||
- true-baseline mode
|
||||
- root `mq` retained
|
||||
- subscriber shaping tree removed
|
||||
- per-circuit IP mappings cleared before teardown
|
||||
- mappings republished after returning to `shape`
|
||||
|
||||
Important operator boundary:
|
||||
|
||||
- `observe` is intentionally honest, not hitless
|
||||
- switching `observe <-> shape` can briefly interrupt traffic because the shaping tree really is removed and later rebuilt
|
||||
- this is different from ordinary `shape -> shape` updates, where LibreQoS tries to preserve stable handles and queue placement where possible
|
||||
|
||||
### 8.4 Retained-root full reload behavior
|
||||
|
||||
Current Bakery full reloads try to avoid unnecessary root churn.
|
||||
|
||||
1. Verify live kernel `tc` state, not just planned state.
|
||||
2. If the root `mq` is healthy and matches the expected layout, retain it.
|
||||
3. Prune child qdiscs beneath the retained root and verify the subtree is clean.
|
||||
4. Rebuild the shaping tree beneath that retained root.
|
||||
5. Fall back to root recovery only when retained-root reuse is unsafe or verification fails.
|
||||
|
||||
This retained-root strategy reduces avoidable root-level churn, while still preferring explicit recovery when live state is ambiguous.
|
||||
|
||||
### 8.5 Reload boundary quick rules
|
||||
|
||||
1. Prefer frequent small mapping/speed deltas over large structural churn.
|
||||
2. Batch topology surgery into planned windows.
|
||||
3. Expect higher risk when many circuits and many structure-affecting changes happen together.
|
||||
4. Build operations cadence around incremental-safe updates by default.
|
||||
|
||||
## 8) Design Boundaries for Operators
|
||||
### 8.6 Full-reload safety guards
|
||||
|
||||
### 8.1 Observability boundaries
|
||||
Current Bakery full reloads apply two conservative safety checks before and during large queue rebuilds:
|
||||
|
||||
1. A qdisc preflight estimates planned qdiscs per interface and also separates infrastructure, `cake`, and `fq_codel` leaf qdiscs.
|
||||
2. That same preflight applies a conservative memory forecast and hard-blocks clearly unsafe full reloads before `tc -batch` starts.
|
||||
3. During chunked full reload apply, Bakery re-checks host memory at chunk boundaries and aborts the remaining apply if available memory drops below its safety floor.
|
||||
4. These guards are intentionally biased toward false positives on large reloads so the system fails early with diagnostics instead of spiraling into an OOM event.
|
||||
|
||||
### 8.7 Runtime safety model
|
||||
|
||||
Bakery's newer runtime-safety direction is intentionally narrow:
|
||||
|
||||
1. Reconcile enough live state to decide whether deferred cleanup is safe.
|
||||
2. Detect material drift between Bakery's intended state and live kernel `tc` state.
|
||||
3. Stop trusting incremental/runtime mutation once drift is real.
|
||||
4. Escalate to a controlled full reload as the recovery path.
|
||||
|
||||
This is intentionally **not** a broad self-healing reconciler. LibreQoS is biased toward:
|
||||
|
||||
- lightweight cleanup gating for expected lag
|
||||
- explicit `reload required` escalation on material live-state drift
|
||||
- one controlled full reload to re-establish a single authoritative queue model
|
||||
|
||||
That design keeps failure handling easier to reason about than trying to incrementally repair arbitrary split-brain queue state.
|
||||
|
||||
### 8.8 Explicit qdisc-handle management
|
||||
|
||||
Bakery now treats leaf qdisc handles as persistent runtime state rather than disposable auto-allocation details.
|
||||
|
||||
1. Circuit leaf qdiscs are assigned explicit handle majors and persisted across applies.
|
||||
2. Handle assignments rotate when a live mutation changes the effective leaf qdisc kind or qdisc parent.
|
||||
3. Full reload planning reserves live handle majors so rebuilds do not collide with surviving kernel state.
|
||||
4. Parent-changed live migration is rejected if Bakery detects stale-handle reuse that would make final state ambiguous.
|
||||
|
||||
This handle model is one of the mechanisms that makes common live circuit migration safer than earlier Bakery generations.
|
||||
|
||||
### 8.9 Runtime virtualization limits and operator expectations
|
||||
|
||||
Current runtime virtualization support is intentionally constrained.
|
||||
|
||||
1. Non-top-level runtime virtualization is limited to same-queue / same-major-domain subtree paths.
|
||||
2. Top-level runtime virtualization uses a separate rebalance/promote path and only applies when Bakery can derive a deterministic split.
|
||||
3. Runtime operations may remain in `AppliedAwaitingCleanup` while deferred prune work completes.
|
||||
4. Runtime operations can become `Dirty`; repeated dirty subtree states escalate to `reload required` rather than attempting broad self-healing.
|
||||
|
||||
Operator takeaway:
|
||||
|
||||
- treat runtime virtualization as a narrow live-mutation feature with verification gates
|
||||
- treat `reload required` as the authoritative signal that Bakery no longer trusts incremental topology mutation
|
||||
|
||||
## 9) Design Boundaries for Operators
|
||||
|
||||
### 9.1 Observability boundaries
|
||||
|
||||
| Signal | Strong for | Weak for |
|
||||
|---|---|---|
|
||||
@@ -285,32 +402,47 @@ flowchart TD
|
||||
| CAKE/fq_codel drops/marks | Detecting persistent queue pressure and policy effects | Full end-to-end application blame assignment |
|
||||
| CPU/RAM and command timing | Capacity and reload risk planning | Isolating every microburst source |
|
||||
|
||||
### 9.2 Metric sample semantics and clock domains
|
||||
|
||||
### 8.2 Capacity risk factors and mitigations
|
||||
Backend metrics do not all come from the same sampling source or clock edge.
|
||||
|
||||
1. Some values are sampled from queue/kernel state.
|
||||
2. Some values are sampled from flow telemetry.
|
||||
3. Some rollups are built from canonical raw samples and only later rendered as percentages.
|
||||
|
||||
Practical implication:
|
||||
|
||||
- do not casually combine unrelated numerator/denominator sources and assume they describe the same exact second
|
||||
- prefer transporting canonical samples/counts and deriving percentages at presentation time
|
||||
- treat “same field name” and “same clock domain” as separate questions
|
||||
|
||||
This matters especially for retransmit, packet, and rate-derived health metrics.
|
||||
|
||||
### 9.3 Capacity risk factors and mitigations
|
||||
|
||||
| Risk factor | Typical symptom | Mitigation |
|
||||
|---|---|---|
|
||||
| Very high queue counts with CAKE everywhere | RAM growth and scheduler overhead | Use `lazy_queues`, expiry, selective fq_codel where appropriate |
|
||||
| Frequent full-tree updates | Brief packet disruption windows | Increase incremental-safe update usage; batch structural changes |
|
||||
| Frequent full-tree updates | Brief packet disruption windows | Increase incremental-safe update usage; batch structural changes, and let Bakery keep ordinary circuit moves incremental where possible |
|
||||
| Incomplete parent mapping in hierarchy | Subscribers unexpectedly unshaped | Validate parent relationships in `network.json` and input data |
|
||||
| Single-queue/weak NIC virtualization behavior | Poor spread and unstable shaping | Ensure multi-queue NIC path and verify queue mapping assumptions |
|
||||
|
||||
## 9) Symptom-to-Cause Troubleshooting Matrix
|
||||
## 10) Symptom-to-Cause Troubleshooting Matrix
|
||||
|
||||
| Symptom | Common backend cause | First checks | Typical corrective direction |
|
||||
|---|---|---|---|
|
||||
| Latency spikes but interface throughput is not fully pegged | Microburst queue buildup, poor flow isolation, or direction mismatch | Compare latency vs queue/drop trends; verify both directions are shaped | Tune leaf qdisc strategy and verify directional shaping design |
|
||||
| One CPU runs hot while others are underused | Queue steering imbalance or weak multi-queue path | Inspect CPU utilization and per-class counters by queue branch | Fix queue mapping assumptions and verify `mq`/class structure |
|
||||
| Subscribers intermittently appear unshaped | Parent/hierarchy mapping mismatch | Validate parent node references and resulting class creation | Correct hierarchy mappings, then apply and verify class presence |
|
||||
| Frequent short disruption during updates | Too many full-reload-triggering changes | Classify recent changes as structural vs incremental | Re-batch operations to favor incremental-safe deltas |
|
||||
| Frequent short disruption during updates | Too many full-reload-triggering changes or runtime drift escalation | Classify recent changes as structural vs incremental, and check for `reload required` events | Re-batch operations to favor incremental-safe deltas and investigate live-state drift |
|
||||
| RAM growth during scale-up | Too many active leaf qdiscs or aggressive CAKE footprint | Measure queue count and memory trends over update windows | Use lazy queue creation/expiry and consider selective fq_codel use |
|
||||
| Dashboard traffic appears higher than expected user throughput | Counter scope differs from post-drop forwarded traffic | Compare dashboard metrics with `tc` drop/mark context | Align runbooks to metric semantics before escalating |
|
||||
|
||||
## 10) Change Validation Workflow
|
||||
## 11) Change Validation Workflow
|
||||
|
||||
Use this lightweight workflow for backend-impacting changes.
|
||||
|
||||
### 10.1 Pre-change
|
||||
### 11.1 Pre-change
|
||||
|
||||
1. Classify change type: mapping/speed/structure.
|
||||
2. Estimate impact scope: number of affected circuits/classes.
|
||||
@@ -320,7 +452,7 @@ Use this lightweight workflow for backend-impacting changes.
|
||||
- CPU and RAM headroom
|
||||
- `tc` class/qdisc snapshot
|
||||
|
||||
### 10.2 During change
|
||||
### 11.2 During change
|
||||
|
||||
1. Watch control-plane behavior:
|
||||
- incremental apply vs full reload occurrence
|
||||
@@ -330,14 +462,19 @@ Use this lightweight workflow for backend-impacting changes.
|
||||
- directional latency drift
|
||||
- per-class counter discontinuities
|
||||
|
||||
### 10.3 Post-change
|
||||
### 11.3 Post-change
|
||||
|
||||
1. Re-check the same baseline signals.
|
||||
2. Confirm hierarchy/class presence for changed circuits.
|
||||
3. Verify subscriber-facing latency and throughput expectations.
|
||||
4. If degraded, rollback or reduce change scope and re-apply in smaller batches.
|
||||
|
||||
### 10.4 Minimal command checklist
|
||||
For `observe <-> shape` transitions, add two specific checks:
|
||||
|
||||
1. confirm the queue mode actually matches the intended state
|
||||
2. confirm per-circuit mappings were cleared or republished at the right phase of the transition
|
||||
|
||||
### 11.4 Minimal command checklist
|
||||
|
||||
Adjust device names to your environment.
|
||||
|
||||
@@ -347,7 +484,7 @@ tc -s class show dev <ifname>
|
||||
journalctl -u lqosd --since "15 min ago"
|
||||
```
|
||||
|
||||
## 11) Practical Tuning Sequence
|
||||
## 12) Practical Tuning Sequence
|
||||
|
||||
Recommended order:
|
||||
|
||||
@@ -358,7 +495,7 @@ Recommended order:
|
||||
5. Tune update cadence to favor incremental-safe changes.
|
||||
6. Re-test after major speed-plan, topology, or integration-cadence changes.
|
||||
|
||||
## 12) Glossary
|
||||
## 13) Glossary
|
||||
|
||||
- `XDP`: earliest high-performance packet hook in Linux.
|
||||
- `eBPF`: in-kernel programmable packet processing.
|
||||
|
||||
@@ -7,18 +7,46 @@ This page documents key WebUI (Node Manager) views and operational behavior in t
|
||||
### Dashboard
|
||||
- Widget-based overview of throughput, retransmits, RTT, flow counts, and queue activity.
|
||||
- Dashboard content can vary by version and enabled features.
|
||||
- Executive Summary provides a compact operational view for large networks, with a `Network Snapshot` focused on throughput, inventory, and Insight state plus drilldown pages for executive heatmaps and leaderboards.
|
||||
- Bakery provides dedicated status for queue apply state, qdisc safety/preflight results, circuit live-change progress, and recent Bakery operations.
|
||||
- The Bakery and TreeGuard tabs present a high-level pipeline or control-loop summary ahead of the more detailed tables.
|
||||
- The Bakery `Pipeline` widget shows queue-control stages, apply state, verification state, and TC interval timing.
|
||||
- `Runtime Operations` summarizes TreeGuard/Bakery topology mutations, deferred cleanup work, failures, and subtrees waiting for a full reload.
|
||||
- `Recent Bakery Events` emphasizes grouped operations, with detailed event history available when deeper troubleshooting is needed.
|
||||
- `TreeGuard Activity` emphasizes grouped operations, including SQM change batches, with detailed event history available when deeper troubleshooting is needed.
|
||||
- `TreeGuard Control Loop` shows the current observe/evaluate/act state.
|
||||
- `TreeGuard Decision Impact` focuses on current impact and any active warnings or errors.
|
||||
- `TreeGuard State Mix` shows managed nodes, runtime virtualization, managed circuits, and the `cake / mixed / fq_codel` circuit SQM split.
|
||||
- Bakery qdisc preflight summarizes planned per-interface qdisc usage and budget headroom before apply.
|
||||
- Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart.
|
||||
- During a Bakery full reload, queue-count cards can temporarily continue showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading`.
|
||||
|
||||
### Network Tree Overview
|
||||
- Hierarchical topology view of nodes/circuits from the shaper perspective.
|
||||
- Useful for spotting bottlenecks and parent/child utilization patterns.
|
||||
- Tree detail pages show a breadcrumb path, branch counts, and status indicators for the selected node.
|
||||
- `Node Details` summarizes the selected node’s type, branch size, configured rates, and effective rate.
|
||||
- `Node Snapshot` provides a quick visual summary of throughput and QoO for the selected node.
|
||||
- Attached circuits are shown in a dedicated table for the selected node.
|
||||
- The attached-circuits IP column keeps rows compact by showing one address inline and collapsing additional addresses as `+X`, with the full list still available on hover.
|
||||
- Ethernet-limited attached circuits can show inline `10M`, `100M`, or `1G` warning badges beside the `Plan (Mbps)` value; hovering explains the auto-cap and clicking the badge opens the dedicated Ethernet review page.
|
||||
- Administrators can save or clear `Operator Override` values where node-level overrides are supported. Read-only users and unsupported nodes continue to display current values without edit controls.
|
||||
- Tree-page operator rate edits write operator-owned overrides to `lqos_overrides.json`; they do not rewrite legacy integration bandwidth CSV files.
|
||||
- Tree-page operator rate edits require an administrator session, a stable node ID, and a non-generated node. Generated/integration-synthetic nodes remain read-only in this editor.
|
||||
|
||||
### Site Map
|
||||
- Flat operational map of Sites and APs using imported node geodata.
|
||||
- Defaults to QoO coloring with an RTT toggle, while marker size reflects recent combined throughput.
|
||||
- Uses a 30-second client-side average from `NetworkTree` data rather than adding backend rollup work.
|
||||
- APs can inherit parent site coordinates for display when explicit AP coordinates are missing.
|
||||
- Uses a local LibreQoS-themed basemap with country/state borders, coastlines, major lakes, major rivers, marine areas, subtle physical-region overlays, and higher-zoom major-highway context for geographic orientation.
|
||||
- Site Map uses a local Natural Earth-derived roads layer for orientation while keeping the rest of the basemap quiet and operational.
|
||||
- Nearby site markers cluster and expand as the operator zooms or selects a cluster.
|
||||
- APs without explicit coordinates are represented through their parent site and can be expanded temporarily around the selected site for inspection.
|
||||
- Visible unclustered sites show labels as the operator zooms in, and the selected site keeps its label visible while it is being inspected.
|
||||
- When browser redaction mode is enabled, Site Map replaces displayed site names with `[redacted]` while leaving the underlying topology data unchanged.
|
||||
- Initial map framing prefers site coordinates for the first view, falling back to AP coordinates when no sites are mapped yet.
|
||||
- Site Map uses an Insight-hosted OpenStreetMap raster tile cache.
|
||||
- In dark mode, the raster underlay is muted and tinted client-side toward the same cooler blue/cyan palette used by Flow Globe, so roads and geography stay visible without the bright light-theme basemap glare.
|
||||
- Site Map depends on outbound access to `https://insight.libreqos.com` for initial bbox/bootstrap and raster tile fetches.
|
||||
- When tiles are missing from the remote cache, the browser retries automatically for a short period instead of failing immediately, so initial map paint can lag briefly on cold tiles.
|
||||
|
||||
### Flow Globe
|
||||
- Geographic flow visualization based on endpoint geolocation.
|
||||
@@ -31,23 +59,38 @@ This page documents key WebUI (Node Manager) views and operational behavior in t
|
||||
### ASN Analysis
|
||||
- Live ASN operations page combining a top-20 ASN leaderboard, latency-vs-traffic bubble chart, selected ASN KPI strip, 15-minute ASN trend chart, and embedded Flow Evidence.
|
||||
- Supports `Impact` and `Throughput` ranking modes while keeping ASN flow evidence on the same page.
|
||||
- The legacy `ASN Explorer` route now redirects here so older bookmarks still work.
|
||||
- Older `ASN Explorer` bookmarks continue to work through a redirect.
|
||||
- Empty results usually indicate low recent data volume rather than failure.
|
||||
|
||||
### Circuit page
|
||||
- Circuit pages combine queue behavior, live throughput, RTT, retransmits, and per-flow troubleshooting for an individual subscriber/circuit.
|
||||
- When integration metadata reports a negotiated CPE Ethernet speed, the `Max` row can show a warning badge such as `100M`; hovering the badge explains when LibreQoS auto-capped shaping below the requested plan to stay within that port speed, and clicking the badge opens the Ethernet review page.
|
||||
- `Queue Dynamics` shows circuit throughput and RTT behavior over time, including an `Active Flows` KPI based on the same recent flow window used by the `Traffic Flows` table.
|
||||
- `Top ASNs` summarizes the busiest recent ASNs on the circuit from that same live flow window and sorts by current rate by default.
|
||||
- `Devices` shows per-device detail tables and live charts for throughput, retransmits, and latency.
|
||||
- `Queue Stats` shows recent live queue history for the circuit, including backlog, delay, queue length, traffic, ECN marks, and drops.
|
||||
- Queue Stats charts use synchronized hover so operators can inspect the same second across all queue charts together.
|
||||
- `Queue Tree` shows the circuit's live upstream queue path, including a path summary and per-node throughput, retransmit, and latency context.
|
||||
- `Traffic Flows` is a recent-flow operational table rather than a long-term history view.
|
||||
- `Traffic Flows` includes paging and a `Hide Small Flows` filter so large busy circuits remain usable without trying to render every row at once.
|
||||
- `Traffic Flows` current-rate display is limited to plausible, plan-aware values for the circuit.
|
||||
- Long text in the `Protocol`, `ASN`, and `Country` columns is truncated with an ellipsis to keep row height stable; the full value remains available on hover.
|
||||
- `Flow Sankey` emphasizes the hottest recent flows rather than every older retained flow.
|
||||
|
||||
### Ethernet Caps
|
||||
- The Ethernet review page is a lightweight operator table of circuits automatically down-rated because detected Ethernet speed was below the requested plan.
|
||||
- It is intentionally not in the main navigation; operators reach it by clicking Ethernet warning badges on the Circuit page or Tree attached-circuits table.
|
||||
- The page supports search, tier filtering (`10M`, `100M`, `1G+`), and paging across auto-capped circuits.
|
||||
|
||||
### CPU Tree / CPU Weights
|
||||
- Shows queue/circuit distribution by CPU core.
|
||||
- Helps evaluate binpacking and load distribution behavior.
|
||||
- CPU Affinity starts with shaping CPUs only, while excluded or host-only cores can be shown when needed.
|
||||
|
||||
### Shaped Devices Editor
|
||||
- CRUD editor for `ShapedDevices.csv`.
|
||||
- Supports paging and filtering in current builds.
|
||||
- Supports paging and filtering.
|
||||
- Add, edit, and delete actions save immediately in the dedicated editor.
|
||||
|
||||
### Urgent Issues
|
||||
- WebUI can display urgent operational issues generated by backend services.
|
||||
@@ -67,6 +110,7 @@ This page documents key WebUI (Node Manager) views and operational behavior in t
|
||||
- Toggle with the mask icon in the top navigation.
|
||||
- Redaction is client-side and stored in browser local storage.
|
||||
- Redaction helps hide PII in screenshots/demos.
|
||||
- Site Map replaces displayed site names with `[redacted]` while redaction mode is enabled.
|
||||
- Redaction does not modify `ShapedDevices.csv`, `network.json`, or any backend data.
|
||||
|
||||
## Common Empty-State Behavior
|
||||
@@ -87,6 +131,8 @@ When this happens:
|
||||
journalctl -u lqosd --since "10 minutes ago"
|
||||
```
|
||||
|
||||
If only Site Map is blank or slow while other pages are healthy, also check management-network reachability to `insight.libreqos.com` and allow a short delay for cold tile-cache retries.
|
||||
|
||||
## Useful Related Pages
|
||||
|
||||
- [Components](components.md)
|
||||
|
||||
+82
-33
@@ -1,12 +1,21 @@
|
||||
# Quickstart: ISP Deployment Path
|
||||
|
||||
Use this page to move from install to a safe pilot with minimal ambiguity.
|
||||
Use this page to move from install to a safe pilot.
|
||||
|
||||
Most ISPs, should use a LibreQoS built-in integration.
|
||||
|
||||
Use this page in order:
|
||||
1. Complete install and bridge setup.
|
||||
2. Complete first-run WebUI access.
|
||||
3. Pass the 10-minute health check.
|
||||
4. Pick one source of truth owner.
|
||||
5. Follow the recommended path for that owner.
|
||||
|
||||
Need definitions for key terms? See the [Glossary](glossary.md).
|
||||
|
||||
## 1) Common Install Foundation
|
||||
|
||||
Complete this once:
|
||||
Complete this once before trying to shape live traffic:
|
||||
|
||||
1. Review architecture and sizing:
|
||||
- [Deployment Scenarios](design.md)
|
||||
@@ -16,9 +25,11 @@ Complete this once:
|
||||
- [Server Setup - Prerequisites](prereq.md)
|
||||
- [Install Ubuntu Server 24.04](ubuntu-server.md)
|
||||
|
||||
3. Configure bridge mode:
|
||||
3. Configure how traffic will pass through the LibreQoS box:
|
||||
- [Configure Shaping Bridge](bridge.md)
|
||||
|
||||
This step is required before production use. If bridge/interface setup is wrong, later WebUI and shaping checks will be misleading.
|
||||
|
||||
4. Install LibreQoS (`.deb` recommended):
|
||||
|
||||
```bash
|
||||
@@ -42,15 +53,25 @@ sudo /opt/libreqos/src/systemd_hotfix.sh install
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
The hotfix installer bootstraps the LibreQoS APT repo at `https://repo.libreqos.com`, installs the patched Noble `systemd` package set, and pins those packages for future updates.
|
||||
|
||||
After the reboot, resume the install:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget https://download.libreqos.com/{deb_url_v1_5}
|
||||
sudo apt install ./{deb_url_v1_5}
|
||||
```
|
||||
|
||||
5. Open WebUI at `http://your_shaper_ip:9123`.
|
||||
|
||||
If no WebUI users exist yet, current builds redirect you to the first-run setup page automatically instead of presenting a normal login flow.
|
||||
|
||||
6. Complete first-run access:
|
||||
- Create the initial WebUI user if prompted.
|
||||
- Confirm you can sign in.
|
||||
- Confirm the Dashboard opens before doing any integration or topology work.
|
||||
|
||||
## 2) 10-Minute Health Check
|
||||
|
||||
Run:
|
||||
@@ -64,54 +85,65 @@ Confirm:
|
||||
- WebUI Dashboard loads.
|
||||
- Scheduler Status is healthy.
|
||||
- No urgent/fatal startup errors in logs.
|
||||
- You can sign in successfully after first-run setup.
|
||||
|
||||
If this fails, go to [Troubleshooting](troubleshooting.md) before proceeding.
|
||||
|
||||
## 3) Decision A: Deployment Stage
|
||||
## 3) Pick One Source of Truth Owner
|
||||
|
||||
This is the most important early decision.
|
||||
|
||||
Source of truth means: the one place that should own persistent shaping data.
|
||||
|
||||
If you make persistent changes somewhere else, they may be overwritten later.
|
||||
|
||||
Choose one:
|
||||
|
||||
- **Lab first**: validate behavior in a controlled environment before inline traffic.
|
||||
- **Inline pilot now**: proceed directly with limited production traffic.
|
||||
|
||||
If lab-first:
|
||||
1. Build a lab topology.
|
||||
2. Generate test traffic.
|
||||
3. Validate Dashboard, Tree, Flow, Scheduler Status, and Urgent Issues.
|
||||
4. Continue to Decision B.
|
||||
|
||||
## 4) Decision B: Source of Truth (Pick One Owner)
|
||||
|
||||
| If this describes you | Mode | Owner of durable shaping data |
|
||||
|---|---|---|
|
||||
| You use a supported CRM/NMS integration | Built-in integration mode | Integration jobs |
|
||||
| You generate `network.json` and `ShapedDevices.csv` with your own scripts | Custom source of truth mode | Your scripts |
|
||||
| You maintain files manually for a small/simple network | Manual files mode | Manual edits |
|
||||
| You use a supported LibreQoS CRM/NMS integration | Built-In Integration Mode | Integration jobs |
|
||||
| You generate `network.json` and `ShapedDevices.csv` with your own automation | Custom Source of Truth Mode | Your scripts |
|
||||
| You intentionally maintain files by hand | Manual Files Mode | Manual edits |
|
||||
|
||||
Rule: keep one owner for persistent shaping inputs.
|
||||
|
||||
## 5) Path Cards
|
||||
Most operators should stop here and choose **Built-In Integration Mode**.
|
||||
|
||||
### Built-In Integration Mode
|
||||
Only choose the other two modes if you already know why.
|
||||
|
||||
## 4) Recommended Path: Built-In Integration Mode
|
||||
|
||||
This is the default path for most ISPs, including most small ISPs.
|
||||
|
||||
When to choose:
|
||||
- Your CRM/NMS is supported by built-in integrations.
|
||||
|
||||
Do this now:
|
||||
1. Configure integration settings in WebUI.
|
||||
2. Run initial sync and validate imported shaping/topology data.
|
||||
3. Place LibreQoS inline for pilot traffic.
|
||||
4. Validate Scheduler Status, Urgent Issues, and topology/flow views.
|
||||
5. Expand pilot scope after stable operation.
|
||||
2. Run the initial sync.
|
||||
3. Confirm outputs were created or refreshed:
|
||||
- `network.json`
|
||||
- `ShapedDevices.csv`
|
||||
4. Confirm Scheduler Status is healthy.
|
||||
5. Confirm Network Tree reflects the expected hierarchy depth for your chosen integration strategy.
|
||||
6. Confirm there are no urgent/fatal issues that block shaping.
|
||||
7. Only then move to limited pilot traffic or inline use.
|
||||
|
||||
Do not do this:
|
||||
- Do not hand-edit integration-owned files as your normal workflow.
|
||||
- Do not split ownership between integration syncs and manual file edits unless you intentionally want that complexity.
|
||||
- Do not change overwrite behavior unless you understand how your integration should own `network.json`.
|
||||
|
||||
Next:
|
||||
- [CRM/NMS Integrations](integrations.md)
|
||||
- [Troubleshooting](troubleshooting.md)
|
||||
|
||||
### Custom Source of Truth (Your Scripts)
|
||||
## 5) Other Paths
|
||||
|
||||
### Custom Source of Truth Mode
|
||||
|
||||
When to choose:
|
||||
- Your CRM/NMS is unsupported and you generate `network.json` + `ShapedDevices.csv` with your own pipeline.
|
||||
- Your CRM/NMS is unsupported and you already generate `network.json` + `ShapedDevices.csv` with your own pipeline.
|
||||
|
||||
Do this now:
|
||||
1. Implement script/process to generate and refresh shaping files.
|
||||
@@ -127,17 +159,22 @@ Next:
|
||||
- [Operating Modes and Source of Truth](operating-modes.md)
|
||||
- [Troubleshooting](troubleshooting.md)
|
||||
|
||||
### Manual Files Mode (<100 Subscribers)
|
||||
### Manual Files Mode
|
||||
|
||||
When to choose:
|
||||
- You intentionally maintain `network.json` + `ShapedDevices.csv` without CRM/NMS synchronization.
|
||||
- Your network is small/simple enough that manual discipline is realistic.
|
||||
- You are doing a temporary pilot or working around an unsupported system.
|
||||
|
||||
Do this now:
|
||||
1. Build and maintain shaping files directly.
|
||||
2. Place LibreQoS inline for pilot traffic.
|
||||
3. Validate shaping and scheduler status in WebUI.
|
||||
4. Maintain strict manual change discipline.
|
||||
5. Plan migration to supported integration or scripts if scale/change volume grows.
|
||||
1. Build `network.json`.
|
||||
2. Build `ShapedDevices.csv`.
|
||||
3. Restart or validate services.
|
||||
4. Confirm the expected topology and devices appear in WebUI.
|
||||
5. Validate shaping and scheduler status before adding more complexity.
|
||||
6. Maintain strict manual change discipline.
|
||||
|
||||
This is not the normal recommendation for operators who already use a supported LibreQoS integration.
|
||||
|
||||
Next:
|
||||
- [Advanced Configuration Reference](configuration-advanced.md)
|
||||
@@ -146,10 +183,22 @@ Next:
|
||||
## 6) Common First-Run Mistakes
|
||||
|
||||
- Unclear source of truth ownership between integration and manual edits.
|
||||
- Hand-editing files even though your integration is supposed to own them.
|
||||
- Changing topology depth before passing the health gate.
|
||||
- Skipping post-change service/log validation before pilot traffic.
|
||||
- Treating low-data or cold-start pages as proof that shaping is broken.
|
||||
- Changing too many things before you have one known-good baseline.
|
||||
|
||||
## 7) Related Pages
|
||||
## 7) Done For Day 1 When
|
||||
|
||||
- You can sign in to WebUI successfully.
|
||||
- Dashboard loads.
|
||||
- Scheduler Status is healthy.
|
||||
- No urgent/fatal startup errors are present.
|
||||
- The expected topology or devices appear.
|
||||
- One test subscriber/device behaves as expected.
|
||||
|
||||
## 8) Related Pages
|
||||
|
||||
- [Operating Modes and Source of Truth](operating-modes.md)
|
||||
- [CRM/NMS Integrations](integrations.md)
|
||||
|
||||
@@ -12,6 +12,8 @@ StormGuard is LibreQoS' adaptive queue-adjustment subsystem for congestion and q
|
||||
- Applies bounded adjustments to configured node limits to protect quality under stress.
|
||||
- Exposes status/debug data in the WebUI (Node Manager).
|
||||
|
||||
StormGuard adaptive site-speed changes are stored in the StormGuard override layer. They are not written back into `network.json`.
|
||||
|
||||
## Configuration
|
||||
|
||||
StormGuard is configured in `/etc/lqos.conf` under `[stormguard]`.
|
||||
@@ -41,7 +43,12 @@ If you are testing, start with `dry_run = true` so you can observe decisions bef
|
||||
|
||||
## UI and Debugging
|
||||
|
||||
- WebUI provides StormGuard status and debug views.
|
||||
- WebUI provides a dedicated StormGuard dashboard tab plus status and debug views.
|
||||
- The StormGuard dashboard tab is intended to answer "what is StormGuard doing right now?" with:
|
||||
- summary cards for watched, cooling-down, and recently changed sites
|
||||
- a site list that works for single-site and multi-site watched sets
|
||||
- a selected-site detail panel explaining current limits, last actions, and why StormGuard is holding or changing rates
|
||||
- a recent activity feed for quick operator triage
|
||||
- The StormGuard debug page shows:
|
||||
- current effective limits
|
||||
- evaluation metrics
|
||||
@@ -71,6 +78,7 @@ Also verify:
|
||||
- target node names still match your current `network.json` hierarchy
|
||||
- integration updates have not renamed key nodes/APs
|
||||
- your minimum percentage floors are reasonable for expected traffic profiles
|
||||
- `network.json` still reflects your planned/source-of-truth site rates if you are investigating an unexpected StormGuard reduction
|
||||
- `log_file` path (if configured) is writable by the service user
|
||||
|
||||
## Related Pages
|
||||
|
||||
+60
-2
@@ -32,11 +32,19 @@ TreeGuard evaluates utilization, RTT freshness, CPU guardrails, and optional QoO
|
||||
High-level behavior:
|
||||
|
||||
1. For sustained low-load conditions, TreeGuard may switch a direction from `cake` to `fq_codel`.
|
||||
2. If utilization rises, RTT/QoO guardrails are unsafe, or revert conditions are met, TreeGuard switches back to `cake`.
|
||||
2. If utilization rises, QoO guardrails are unsafe, or revert conditions are met, TreeGuard switches back toward the circuit's base SQM policy.
|
||||
3. Decisions can be independent per direction when `independent_directions = true`.
|
||||
|
||||
This gives a dynamic profile where loaded directions favor `cake diffserv4`, while low-load directions can use `fq_codel` when conditions are safe.
|
||||
|
||||
The base SQM policy comes from operator intent, not from TreeGuard defaults. In practice, TreeGuard starts from the effective configured policy for each circuit and only persists its own temporary overlay when it needs to differ from that base.
|
||||
|
||||
Important base-policy rule:
|
||||
|
||||
1. If a direction's base SQM policy is `cake`, TreeGuard may temporarily switch that direction to `fq_codel` and later return it to base.
|
||||
2. If a direction's base SQM policy is `fq_codel`, TreeGuard does not circuit-switch that direction to `cake`.
|
||||
3. Link virtualization remains available regardless of the circuit SQM base policy.
|
||||
|
||||
## Configuration (`/etc/lqos.conf`)
|
||||
|
||||
TreeGuard config lives under `[treeguard]` and sub-sections:
|
||||
@@ -55,6 +63,11 @@ enabled = true
|
||||
dry_run = false
|
||||
tick_seconds = 1
|
||||
|
||||
[treeguard.cpu]
|
||||
mode = "cpu_aware"
|
||||
cpu_high_pct = 75
|
||||
cpu_low_pct = 55
|
||||
|
||||
[treeguard.links]
|
||||
enabled = true
|
||||
all_nodes = true
|
||||
@@ -70,6 +83,11 @@ independent_directions = true
|
||||
enabled = true
|
||||
```
|
||||
|
||||
TreeGuard node virtualization is intended to be CPU-aware by default. Traffic, RTT, and QoO
|
||||
remain important safety and restore signals, but new automatic virtualization should happen when
|
||||
CPU pressure suggests HTB savings are worthwhile. Upgraded installs from older defaults are
|
||||
silently migrated from `traffic_rtt_only` to `cpu_aware`, with a visible notice in logs/UI.
|
||||
|
||||
## Safe Rollout Pattern
|
||||
|
||||
1. Review TreeGuard settings early in deployment instead of assuming static/manual queue behavior.
|
||||
@@ -80,12 +98,52 @@ enabled = true
|
||||
|
||||
## Overrides and Operational Notes
|
||||
|
||||
When enabled and not dry-run, TreeGuard may persist decisions to:
|
||||
When enabled and not dry-run, TreeGuard may persist circuit SQM decisions to:
|
||||
|
||||
- `lqos_overrides.treeguard.json`
|
||||
|
||||
TreeGuard is designed to avoid fighting operator-owned overrides. If operator overrides exist for enrolled entities, TreeGuard skips those entities and reports warnings.
|
||||
|
||||
TreeGuard virtual-node decisions are runtime-only Bakery operations. They are not materialized back
|
||||
into the base `network.json` by the scheduler, and they are not persisted as TreeGuard-owned
|
||||
`set_node_virtual` entries in the effective shaping input. In v1 they are ephemeral: a daemon
|
||||
restart returns the physical tree to the base operator-defined topology until TreeGuard decides
|
||||
again.
|
||||
|
||||
For local runtime verification and debugging, `liblqos_python` now exposes both the current
|
||||
TreeGuard node-operation status and a Bakery runtime branch-state snapshot. The branch-state
|
||||
snapshot is the authoritative control-plane view of which retained branch is active for a node, and
|
||||
is more reliable than inferring parent changes from `tc` output alone for non-top-level cases.
|
||||
The local confidence workflow also now uses a larger synthetic Bakery TreeGuard fixture for scale
|
||||
checks: 8 top-level nodes, 3 levels deep, and 1,000 circuits attached only at the lowest level.
|
||||
When traffic-backed verification is enabled, the runtime verifier now defaults to 10 tracked
|
||||
circuits per successful case instead of the earlier smaller smoke-test default.
|
||||
|
||||
TreeGuard circuit SQM decisions are also runtime overrides. The scheduler does not materialize TreeGuard-owned SQM changes back into the base `ShapedDevices.csv`, so clearing TreeGuard does not permanently rewrite operator-authored circuit SQM policy.
|
||||
|
||||
TreeGuard also refuses to manage nodes that are already marked `"virtual": true` in the base `network.json`. If stale legacy TreeGuard-owned node-virtualization overrides exist for those nodes, TreeGuard clears that legacy override state and falls back to the base topology definition.
|
||||
|
||||
For circuit SQM management, TreeGuard treats duplicate `device_id` values as unsafe identity collisions. If the same `device_id` appears in more than one circuit in `ShapedDevices.csv`, TreeGuard skips those affected circuits and clears any TreeGuard-owned SQM overrides for those duplicate device IDs.
|
||||
|
||||
If RTT telemetry is temporarily unavailable after a restart, TreeGuard does not treat missing RTT alone as evidence that it should revert `fq_codel` directions. Other guardrails such as utilization, QoO, and CPU pressure still apply.
|
||||
|
||||
TreeGuard also applies a conservative global per-tick circuit SQM change budget. On very large enrolled populations, excess circuit SQM changes are deferred to later ticks instead of stampeding Bakery in one pass.
|
||||
|
||||
For scale, TreeGuard no longer rebuilds circuit membership from `ShapedDevices.csv` on every circuit tick. It now keeps a cached per-circuit inventory derived from `ShapedDevices.csv`, reads per-circuit live telemetry from the shared once-per-second circuit rollup snapshot, and spreads large `all_circuits = true` SQM evaluations across multiple ticks instead of rescanning every enrolled circuit every second.
|
||||
|
||||
In practice, this means:
|
||||
|
||||
1. Link virtualization still follows the normal TreeGuard tick cadence.
|
||||
2. Circuit SQM evaluation for small enrollments still completes quickly.
|
||||
3. Very large `all_circuits` enrollments are swept incrementally over multiple ticks, with a target full sweep around 15 seconds instead of attempting a full per-second scan.
|
||||
4. TreeGuard node virtualization now goes through Bakery live runtime planning/apply paths instead of forcing a LibreQoS reload or Bakery full reload.
|
||||
5. Supported top-level runtime virtualization now uses a Bakery-side rebalance/migration plan that can promote child sites and direct circuits across queue roots while preserving the logical hierarchy for reporting.
|
||||
|
||||
Recent TreeGuard activity is available in two places:
|
||||
|
||||
- The WebUI TreeGuard status/activity views.
|
||||
- The `lqosd` journal, where TreeGuard now logs each recorded activity event so reloads, override cleanup, SQM changes, and failures are diagnosable without websocket inspection.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [HTB + fq_codel + CAKE: Detailed Queueing Behavior](htb_fq_codel_cake.md)
|
||||
|
||||
@@ -48,13 +48,16 @@ And include:
|
||||
|
||||
### User password not working
|
||||
|
||||
Delete the lqusers file:
|
||||
Current builds will:
|
||||
- migrate older auth files automatically
|
||||
- redirect `/login.html` to `/first-run.html` when no users exist
|
||||
|
||||
If the correct username/password still fails, first restart `lqosd` and try again:
|
||||
```bash
|
||||
sudo systemctl restart lqosd
|
||||
```
|
||||
sudo rm /opt/libreqos/src/lqusers.toml
|
||||
sudo systemctl restart lqosd lqos_scheduler
|
||||
```
|
||||
Then visit: BOX_IP:9123/index.html
|
||||
This will allow you to set up the user again from scratch using the WebUI.
|
||||
|
||||
Only remove `lqusers.toml` if you are intentionally resetting access or if the file is corrupt and cannot be repaired. After removing it, restart `lqosd` and open `BOX_IP:9123/login.html`; the WebUI should redirect you to first-run setup automatically.
|
||||
|
||||
### No WebUI at x.x.x.x:9123
|
||||
|
||||
@@ -129,6 +132,10 @@ sudo journalctl -u lqos_scheduler --since "1 day ago" --no-pager > lqos_sched_lo
|
||||
```
|
||||
This exports a log file to lqos_sched_log.txt. You can review this file to see what caused the scheduler to error out.
|
||||
|
||||
If the scheduler fails immediately after a restart with a message like `Socket (typically /run/lqos/bus) not found`, that indicates `lqosd` had not finished binding the local bus yet. Current builds wait briefly for bus readiness at scheduler startup instead of crashing immediately, so repeated startup panics after restart should no longer be expected.
|
||||
|
||||
If an integration subprocess fails, current builds keep the scheduler alive, publish a shortened output preview to the scheduler status/error surfaces, and save the full captured output to a timestamped file under `/tmp` such as `lqos_scheduler_uisp_integration_YYYYMMDD_HHMMSS.log`.
|
||||
|
||||
### Scheduler status in WebUI looks unhealthy
|
||||
|
||||
Recent builds expose scheduler readiness/state in the WebUI (Node Manager).
|
||||
@@ -194,6 +201,21 @@ journalctl -u lqosd --since "10 minutes ago"
|
||||
|
||||
If still blank under normal traffic, collect recent logs and open an issue.
|
||||
|
||||
### Site Map appears blank or slow
|
||||
|
||||
Site Map has one extra dependency beyond normal WebUI data feeds: current builds fetch bbox/bootstrap data and raster tiles from `https://insight.libreqos.com`.
|
||||
|
||||
If Site Map alone is blank or slow:
|
||||
1. Confirm `lqosd` is healthy.
|
||||
2. Confirm the box can reach `insight.libreqos.com` from its management network.
|
||||
3. Wait briefly and reload the page; the browser retries tile requests automatically while cold tiles are being populated upstream.
|
||||
4. Check recent `lqosd` logs:
|
||||
```
|
||||
journalctl -u lqosd --since "10 minutes ago"
|
||||
```
|
||||
|
||||
If the rest of WebUI is healthy but Site Map continues to fail, treat it as a map/tile dependency issue rather than a general scheduler or shaping failure.
|
||||
|
||||
### Virtual node promotion collision (network.json)
|
||||
|
||||
If LibreQoS.py fails with an error like `Virtual node promotion collision: 'AP_A' already exists at this level.`, you have a `"virtual": true` node whose children get promoted into a parent level where a node with the same name already exists.
|
||||
@@ -234,6 +256,10 @@ WebUI urgent issues include machine-readable codes. Use them to triage quickly.
|
||||
|---|---|---|---|
|
||||
| `MAPPED_CIRCUIT_LIMIT` | Bakery is enforcing a mapped-circuit limit. | Insight license status, `journalctl -u lqosd` for requested/allowed/dropped counts. | Reduce mapped circuits immediately or update license/limits. |
|
||||
| `TC_U16_OVERFLOW` | Queue/class minor IDs exceeded the Linux tc u16 range on a CPU queue. | `journalctl -u lqos_scheduler -u lqosd`, topology depth/queue distribution. | Increase queue count and/or simplify/rebalance hierarchy (for example with integration strategy or root promotion changes). |
|
||||
| `TC_QDISC_CAPACITY` | Planned auto-allocated qdiscs exceed the per-interface safe budget or Bakery's conservative memory-safety preflight before apply. | Estimated per-interface qdisc counts, qdisc-kind breakdown, and memory fields in the urgent issue context, `journalctl -u lqos_scheduler -u lqosd`, `on_a_stick` and `queue_mode` config. | Reduce the planned qdisc load for this run (for example fewer circuits/devices in the test shape) before retrying; do not trust partial apply. |
|
||||
| `BAKERY_MEMORY_GUARD` | A chunked Bakery full reload was stopped mid-apply because available host memory fell below the safety floor. | `journalctl -u lqosd`, available/total memory in the urgent issue context, and recent Bakery apply progress. | Treat the run as failed, reduce memory pressure or queue footprint, and retry only after the host is stable. |
|
||||
| `XDP_IP_MAPPING_CAPACITY` | Required IP mappings exceed the current XDP kernel map capacity. | `ShapedDevices.csv` row shape, IPv4/IPv6 mix, one-device-vs-many-device assumptions, `journalctl -u lqos_scheduler -u lqosd`. | Reduce required mappings immediately (for example fewer devices or IPv4-only test shape), or raise kernel map capacity in a coordinated change. |
|
||||
| `XDP_IP_MAPPING_APPLY_FAILED` | One or more IP mapping inserts failed during apply. | `journalctl -u lqos_scheduler -u lqosd` for summarized failure examples and counts. | Fix the underlying mapping failure, then rerun; do not trust partial shaping. |
|
||||
|
||||
Operational pattern:
|
||||
1. Open urgent issue details in WebUI (code/message/context).
|
||||
|
||||
@@ -39,10 +39,13 @@ sudo /opt/libreqos/src/systemd_hotfix.sh install
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
The hotfix installer bootstraps the LibreQoS APT repo at `https://repo.libreqos.com`, installs the patched Noble `systemd` package set, and pins those packages for future updates.
|
||||
|
||||
After the reboot, resume the upgrade and restart services:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget https://download.libreqos.com/{deb_url_v1_5}
|
||||
sudo apt install ./{deb_url_v1_5}
|
||||
sudo systemctl restart lqosd lqos_scheduler
|
||||
```
|
||||
|
||||
+842
-585
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1 +1 @@
|
||||
2.0
|
||||
2.1
|
||||
+2343
-93
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
[Unit]
|
||||
After=network.service lqosd.service
|
||||
Wants=network-online.target
|
||||
After=network-online.target systemd-resolved.service lqosd.service
|
||||
Requires=lqosd.service
|
||||
|
||||
[Service]
|
||||
@@ -9,4 +10,3 @@ Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[Unit]
|
||||
After=network.service lqosd.service
|
||||
Wants=network-online.target
|
||||
After=network-online.target systemd-resolved.service lqosd.service
|
||||
Requires=lqosd.service
|
||||
|
||||
[Service]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[Unit]
|
||||
After=network.service
|
||||
Wants=network-online.target
|
||||
After=network-online.target systemd-resolved.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/libreqos/src/bin
|
||||
|
||||
+13
-6
@@ -45,7 +45,6 @@ LQOS_FILES=(
|
||||
manualNetwork.template.csv
|
||||
integrationUISProutes.template.csv
|
||||
integrationSplynxBandwidths.template.csv
|
||||
deb-requirements-constraints.txt
|
||||
../requirements.txt
|
||||
update_api.sh
|
||||
)
|
||||
@@ -125,7 +124,7 @@ LibreQoS detected the Ubuntu 24.04 systemd-networkd hotfix requirement on this h
|
||||
Run:
|
||||
sudo /opt/libreqos/src/systemd_hotfix.sh install
|
||||
|
||||
The hotfix installer downloads from https://download.libreqos.com and will offer to schedule a reboot.
|
||||
The hotfix installer bootstraps the LibreQoS APT repo at https://repo.libreqos.com and will offer to schedule a reboot.
|
||||
|
||||
After the reboot, finish the LibreQoS package configuration with:
|
||||
sudo dpkg --configure -a
|
||||
@@ -136,7 +135,11 @@ fi
|
||||
# Install Python Dependencies
|
||||
pushd /opt/libreqos > /dev/null
|
||||
# - Setup Python dependencies as a post-install task
|
||||
if [ -s src/deb-requirements-constraints.txt ]; then
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install -c src/deb-requirements-constraints.txt -r src/requirements.txt
|
||||
else
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install -r src/requirements.txt
|
||||
fi
|
||||
# - Setup Python dependencies as a post-install task - handle issue with packages on Ubuntu Server 24.04
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip uninstall apscheduler deepdiff --yes
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install apscheduler deepdiff
|
||||
@@ -147,14 +150,14 @@ sudo chown -R root:root /opt/libreqos
|
||||
# - Run lqsetup
|
||||
/opt/libreqos/src/bin/lqos_setup
|
||||
# - Setup the services
|
||||
cp /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.service
|
||||
cp /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service
|
||||
cp /opt/libreqos/src/bin/lqos_api.service.example /etc/systemd/system/lqos_api.service
|
||||
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/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 enable lqosd lqos_scheduler lqos_api || true
|
||||
/bin/systemctl start lqosd lqos_scheduler lqos_api || true
|
||||
/bin/systemctl restart lqosd lqos_scheduler lqos_api || true
|
||||
popd > /dev/null
|
||||
EOF
|
||||
|
||||
@@ -175,6 +178,10 @@ for file in "${LQOS_FILES[@]}"; do
|
||||
cp "$file" "$LQOS_DIR" || echo "Error copying $file"
|
||||
done
|
||||
|
||||
if [ -f deb-requirements-constraints.txt ]; then
|
||||
cp deb-requirements-constraints.txt "$LQOS_DIR" || echo "Error copying deb-requirements-constraints.txt"
|
||||
fi
|
||||
|
||||
# Ensure helper scripts are executable in the package
|
||||
if [ -f "$LQOS_DIR/update_api.sh" ]; then
|
||||
chmod a+x "$LQOS_DIR/update_api.sh" || true
|
||||
|
||||
+38
-5
@@ -114,12 +114,48 @@ service_exists() {
|
||||
fi
|
||||
}
|
||||
|
||||
refresh_service_unit() {
|
||||
local unit_name=$1
|
||||
local src="./bin/${unit_name}.service.example"
|
||||
local dst="/etc/systemd/system/${unit_name}.service"
|
||||
if [ -f "$src" ] && [ -f "$dst" ]; then
|
||||
echo "Refreshing $dst from $src"
|
||||
sudo cp "$src" "$dst"
|
||||
SERVICE_UNITS_UPDATED=1
|
||||
fi
|
||||
}
|
||||
|
||||
clear_pinned_maps_before_lqosd_restart() {
|
||||
local script_path="./rust/remove_pinned_maps.sh"
|
||||
if [ ! -x "$script_path" ]; then
|
||||
echo "Expected $script_path to exist and be executable before restarting lqosd."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Removing pinned BPF maps before restarting lqosd."
|
||||
if ! sudo "$script_path"; then
|
||||
echo "Failed to remove pinned maps. Skipping service restarts to avoid restarting lqosd with stale map state."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
SERVICE_UNITS_UPDATED=0
|
||||
refresh_service_unit lqosd
|
||||
refresh_service_unit lqos_scheduler
|
||||
refresh_service_unit lqos_api
|
||||
|
||||
if [ "$SERVICE_UNITS_UPDATED" -eq 1 ]; then
|
||||
echo "Reloading systemd unit definitions."
|
||||
sudo systemctl daemon-reload
|
||||
fi
|
||||
|
||||
if service_exists lqos_node_manager; then
|
||||
echo "lqos_node_manager is running as a service. It's not needed anymore. Killing it."
|
||||
sudo systemctl stop lqos_node_manager
|
||||
sudo systemctl disable lqos_node_manager
|
||||
fi
|
||||
if service_exists lqosd; then
|
||||
clear_pinned_maps_before_lqosd_restart
|
||||
echo "lqosd is running as a service. Restarting it. You may need to enter your sudo password."
|
||||
sudo systemctl restart lqosd
|
||||
fi
|
||||
@@ -135,10 +171,7 @@ fi
|
||||
echo "-----------------------------------------------------------------"
|
||||
echo "Don't forget to setup /etc/lqos.conf!"
|
||||
echo "Template .service files can be found in bin/"
|
||||
echo "Debian package installs constrain Python dependencies with src/deb-requirements-constraints.txt to stay compatible with Ubuntu's distro Python packages."
|
||||
echo "Use ./systemd_hotfix.sh to evaluate or install the Ubuntu 24.04 networkd hotfix bundle from download.libreqos.com."
|
||||
echo "If src/deb-requirements-constraints.txt exists, Debian package installs use it to constrain Python dependencies."
|
||||
echo "Use ./systemd_hotfix.sh to evaluate or install the Ubuntu 24.04 networkd hotfix from the LibreQoS APT repo at https://repo.libreqos.com."
|
||||
echo "The hotfix installer now offers to schedule a reboot after it finishes."
|
||||
echo "LibreQoS package installs on affected Ubuntu 24.04 hosts stop until the hotfix is installed; finish with sudo dpkg --configure -a after the reboot."
|
||||
echo ""
|
||||
echo "Run sudo rust/remove_pinned_maps.sh before you restart lqosd"
|
||||
echo "This ensures that any data-format changes will apply correctly."
|
||||
+90
-23
@@ -41,6 +41,18 @@ def isIpv4Permitted(inputIP):
|
||||
return isInIgnoredSubnets(inputIP) == False and isInAllowedSubnets(inputIP)
|
||||
|
||||
|
||||
def isIpPermitted(inputIP):
|
||||
# Generic form of subnet permission checking used by integrations.
|
||||
# The configured allow/ignore ranges may include either IPv4 or IPv6.
|
||||
return isIpv4Permitted(inputIP)
|
||||
|
||||
|
||||
def isIntegrationOutputIpAllowed(inputIP):
|
||||
# Shared integration-output pruning is intentionally narrower than
|
||||
# full "permitted" checks: only explicitly ignored subnets are removed.
|
||||
return not isInIgnoredSubnets(inputIP)
|
||||
|
||||
|
||||
def fixSubnet(inputIP):
|
||||
# If an IP address has a CIDR other than /32 (e.g. 192.168.1.1/24),
|
||||
# but doesn't appear as a network address (e.g. 192.168.1.0/24)
|
||||
@@ -252,6 +264,7 @@ class NetworkGraph:
|
||||
# O(1) lookup instead of O(n) search
|
||||
parentIdx = self._id_to_index.get(parent, 0)
|
||||
node.parentIndex = parentIdx
|
||||
node.parentId = parent
|
||||
|
||||
idx = len(self.nodes)
|
||||
self.nodes.append(node)
|
||||
@@ -269,6 +282,7 @@ class NetworkGraph:
|
||||
root_list_set = set(cached_root_list)
|
||||
|
||||
for child in self.nodes:
|
||||
child.parentIndex = 0
|
||||
if child.parentId != "":
|
||||
# O(1) lookup instead of O(n) search
|
||||
parent_idx = self._id_to_index.get(child.parentId, -1)
|
||||
@@ -330,26 +344,28 @@ class NetworkGraph:
|
||||
if not self._cache_valid:
|
||||
self._buildChildrenCache()
|
||||
|
||||
for i, node in enumerate(self.nodes):
|
||||
if node.type == NodeType.clientWithChildren:
|
||||
siteNode = NetworkNode(
|
||||
id=str(node.id) + "_gen",
|
||||
displayName="(Generated Site) " + node.displayName,
|
||||
type=NodeType.site,
|
||||
networkJsonId=syntheticNetworkJsonId("graph", "site", node.displayName),
|
||||
)
|
||||
siteNode.parentIndex = node.parentIndex
|
||||
node.parentId = siteNode.id
|
||||
node.type = NodeType.client
|
||||
|
||||
# Store reparenting operations for batch processing
|
||||
children = self._children_cache.get(i, [])
|
||||
for child_idx in children:
|
||||
child = self.nodes[child_idx]
|
||||
if child.type in (NodeType.client, NodeType.clientWithChildren, NodeType.site):
|
||||
reparent_map[child_idx] = siteNode.id
|
||||
|
||||
toAdd.append(siteNode)
|
||||
for i, node in enumerate(self.nodes):
|
||||
if node.type == NodeType.clientWithChildren:
|
||||
original_parent_id = node.parentId
|
||||
siteNode = NetworkNode(
|
||||
id=str(node.id) + "_gen",
|
||||
displayName="(Generated Site) " + node.displayName,
|
||||
type=NodeType.site,
|
||||
networkJsonId=syntheticNetworkJsonId("graph", "site", node.displayName),
|
||||
)
|
||||
siteNode.parentIndex = node.parentIndex
|
||||
siteNode.parentId = original_parent_id
|
||||
node.parentId = siteNode.id
|
||||
node.type = NodeType.client
|
||||
|
||||
# Store reparenting operations for batch processing
|
||||
children = self._children_cache.get(i, [])
|
||||
for child_idx in children:
|
||||
child = self.nodes[child_idx]
|
||||
if child.type in (NodeType.client, NodeType.clientWithChildren, NodeType.site):
|
||||
reparent_map[child_idx] = siteNode.id
|
||||
|
||||
toAdd.append(siteNode)
|
||||
|
||||
# Batch add new nodes
|
||||
for n in toAdd:
|
||||
@@ -362,12 +378,12 @@ class NetworkGraph:
|
||||
self.__reparentById()
|
||||
|
||||
def __findUnconnectedNodes(self) -> List:
|
||||
# OPTIMIZED: Uses set for O(1) membership testing
|
||||
# Rebuild the cache each time because callers may have adjusted
|
||||
# parentIndex directly as part of repair/testing flows.
|
||||
visited = set()
|
||||
next = [0]
|
||||
|
||||
if not self._cache_valid:
|
||||
self._buildChildrenCache()
|
||||
self._buildChildrenCache()
|
||||
|
||||
while next:
|
||||
current = next.pop()
|
||||
@@ -422,6 +438,8 @@ class NetworkGraph:
|
||||
self.__clientsWithChildrenToSites()
|
||||
print("PrepareTree: Reconnecting unconnected nodes...")
|
||||
self.__reconnectUnconnected()
|
||||
print("PrepareTree: Pruning ignored-only devices and empty circuits...")
|
||||
self.__pruneIgnoredCircuits()
|
||||
print("PrepareTree: Complete")
|
||||
|
||||
def doesNetworkJsonExist(self):
|
||||
@@ -502,6 +520,55 @@ class NetworkGraph:
|
||||
if ip in self.ipv4ToIPv6.keys():
|
||||
ipv6.append(self.ipv4ToIPv6[ip])
|
||||
|
||||
def __rebuildIndexes(self) -> None:
|
||||
self._id_to_index = {}
|
||||
self._name_to_index = {}
|
||||
for idx, node in enumerate(self.nodes):
|
||||
self._id_to_index[node.id] = idx
|
||||
self._name_to_index[node.displayName] = idx
|
||||
self._cache_valid = False
|
||||
|
||||
def __filterPermittedIps(self, ip_list: List[str]) -> List[str]:
|
||||
return [ip for ip in ip_list if isIntegrationOutputIpAllowed(ip)]
|
||||
|
||||
def __pruneIgnoredCircuits(self) -> None:
|
||||
for node in self.nodes:
|
||||
if node.type == NodeType.device:
|
||||
node.ipv4 = self.__filterPermittedIps(node.ipv4)
|
||||
node.ipv6 = self.__filterPermittedIps(node.ipv6)
|
||||
|
||||
if not self._cache_valid:
|
||||
self._buildChildrenCache()
|
||||
|
||||
removable = set()
|
||||
for idx, node in enumerate(self.nodes):
|
||||
if node.type == NodeType.device and (len(node.ipv4) + len(node.ipv6) == 0):
|
||||
removable.add(idx)
|
||||
|
||||
for idx, node in enumerate(self.nodes):
|
||||
if node.type != NodeType.client:
|
||||
continue
|
||||
has_shapable_device = False
|
||||
for child_idx in self._children_cache.get(idx, []):
|
||||
if child_idx in removable:
|
||||
continue
|
||||
child = self.nodes[child_idx]
|
||||
if child.type == NodeType.device and (len(child.ipv4) + len(child.ipv6) > 0):
|
||||
has_shapable_device = True
|
||||
break
|
||||
if not has_shapable_device:
|
||||
removable.add(idx)
|
||||
|
||||
if not removable:
|
||||
return
|
||||
|
||||
self.nodes = [
|
||||
node for idx, node in enumerate(self.nodes)
|
||||
if idx not in removable
|
||||
]
|
||||
self.__rebuildIndexes()
|
||||
self.__reparentById()
|
||||
|
||||
def createShapedDevices(self):
|
||||
import csv
|
||||
# Builds ShapedDevices.csv from the network tree.
|
||||
|
||||
+518
-60
@@ -2,8 +2,11 @@ from pythonCheck import checkPythonVersion
|
||||
checkPythonVersion()
|
||||
import requests
|
||||
import subprocess
|
||||
import time
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from liblqos_python import sonar_api_key, sonar_api_url, snmp_community, sonar_airmax_ap_model_ids, \
|
||||
sonar_ltu_ap_model_ids, sonar_active_status_ids
|
||||
sonar_ltu_ap_model_ids, sonar_active_status_ids, sonar_recurring_service_rates, \
|
||||
sonar_recurring_excluded_service_names
|
||||
all_models = sonar_airmax_ap_model_ids() + sonar_ltu_ap_model_ids()
|
||||
from integrationCommon import NetworkGraph, NetworkNode, NodeType, apply_client_bandwidth_multiplier
|
||||
from multiprocessing.pool import ThreadPool
|
||||
@@ -24,18 +27,161 @@ from multiprocessing.pool import ThreadPool
|
||||
# There should probably be a log file somewhere and output status to the Web UI.
|
||||
# If snmp fails to get the name of the AP then it's just called "Not found via snmp" We won't see that happen unless it is able to get the connected cpe and not the name.
|
||||
|
||||
_SONAR_SESSION = None
|
||||
SONAR_CONNECT_TIMEOUT_SECONDS = 10
|
||||
SONAR_READ_TIMEOUT_SECONDS = 60
|
||||
SONAR_REQUEST_TIMEOUT = (SONAR_CONNECT_TIMEOUT_SECONDS, SONAR_READ_TIMEOUT_SECONDS)
|
||||
SONAR_PAGINATED_MAX_ATTEMPTS = 3
|
||||
SONAR_PAGINATED_RETRY_BACKOFF_SECONDS = (2, 5)
|
||||
|
||||
def sonarRequest(query,variables={}):
|
||||
|
||||
r = requests.post(sonar_api_url(), json={'query': query, 'variables': variables}, headers={'Authorization': 'Bearer ' + sonar_api_key()}, timeout=10)
|
||||
r_json = r.json()
|
||||
def sonarGraphqlUrl():
|
||||
raw_url = sonar_api_url().strip()
|
||||
if not raw_url:
|
||||
return raw_url
|
||||
|
||||
parsed = urlsplit(raw_url)
|
||||
path = parsed.path.rstrip('/')
|
||||
|
||||
if path.endswith('/api/graphql'):
|
||||
normalized_path = path
|
||||
elif path in ('', '/'):
|
||||
normalized_path = '/api/graphql'
|
||||
else:
|
||||
normalized_path = path + '/api/graphql'
|
||||
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, normalized_path, parsed.query, parsed.fragment))
|
||||
|
||||
|
||||
def sonarAccountNodeId(account_id):
|
||||
return f"sonar:account:{account_id}"
|
||||
|
||||
|
||||
def sonarDeviceNodeId(device_id):
|
||||
return f"sonar:device:{device_id}"
|
||||
|
||||
|
||||
def sonarRadiusAccountDeviceNodeId(radius_account_id):
|
||||
return f"sonar:radius-account:{radius_account_id}"
|
||||
|
||||
|
||||
def sonarSession():
|
||||
global _SONAR_SESSION
|
||||
if _SONAR_SESSION is None:
|
||||
_SONAR_SESSION = requests.Session()
|
||||
_SONAR_SESSION.headers.update({
|
||||
'Authorization': 'Bearer ' + sonar_api_key()
|
||||
})
|
||||
return _SONAR_SESSION
|
||||
|
||||
|
||||
def sonarPostWithRetry(session, graphql_url, payload, timeout, request_label, max_attempts=1, retry_backoffs=()):
|
||||
last_error = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return session.post(
|
||||
graphql_url,
|
||||
json=payload,
|
||||
timeout=timeout
|
||||
)
|
||||
except requests.exceptions.ReadTimeout as error:
|
||||
last_error = error
|
||||
if attempt >= max_attempts:
|
||||
break
|
||||
backoff_seconds = 0
|
||||
if retry_backoffs:
|
||||
backoff_index = min(attempt - 1, len(retry_backoffs) - 1)
|
||||
backoff_seconds = retry_backoffs[backoff_index]
|
||||
if backoff_seconds > 0:
|
||||
time.sleep(backoff_seconds)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Sonar API at {graphql_url} timed out while reading {request_label} "
|
||||
f"after {max_attempts} attempts"
|
||||
) from last_error
|
||||
|
||||
|
||||
def sonarRequest(query, variables=None):
|
||||
if variables is None:
|
||||
variables = {}
|
||||
|
||||
graphql_url = sonarGraphqlUrl()
|
||||
r = sonarPostWithRetry(
|
||||
sonarSession(),
|
||||
graphql_url,
|
||||
{'query': query, 'variables': variables},
|
||||
SONAR_REQUEST_TIMEOUT,
|
||||
"Sonar request"
|
||||
)
|
||||
try:
|
||||
r_json = r.json()
|
||||
except requests.exceptions.JSONDecodeError as e:
|
||||
body_preview = r.text[:300].strip()
|
||||
raise RuntimeError(
|
||||
f"Sonar API at {graphql_url} returned non-JSON content "
|
||||
f"(status={r.status_code}, content-type={r.headers.get('content-type')}, body-preview={body_preview!r})"
|
||||
) from e
|
||||
|
||||
# Sonar responses look like this: {"data": {"accounts": {"entities": [{"id": '1'},{"id": 2}]}}}
|
||||
# I just want to return the list so I need to find what field we're querying.
|
||||
if 'errors' in r_json:
|
||||
raise RuntimeError(f"Sonar GraphQL error from {graphql_url}: {r_json['errors']}")
|
||||
if 'data' not in r_json:
|
||||
raise RuntimeError(f"Sonar API response from {graphql_url} missing 'data': {r_json}")
|
||||
field = list(r_json['data'].keys())[0]
|
||||
sonar_list = r_json['data'][field]['entities']
|
||||
return sonar_list
|
||||
|
||||
|
||||
def sonarPaginatedRequest(query, variables=None, paginator_key='pages', records_per_page=100):
|
||||
if variables is None:
|
||||
variables = {}
|
||||
|
||||
session = sonarSession()
|
||||
graphql_url = sonarGraphqlUrl()
|
||||
page = 1
|
||||
entities = []
|
||||
|
||||
while True:
|
||||
request_variables = dict(variables)
|
||||
request_variables[paginator_key] = {
|
||||
'records_per_page': records_per_page,
|
||||
'page': page
|
||||
}
|
||||
r = sonarPostWithRetry(
|
||||
session,
|
||||
graphql_url,
|
||||
{'query': query, 'variables': request_variables},
|
||||
SONAR_REQUEST_TIMEOUT,
|
||||
f"Sonar page {page}",
|
||||
max_attempts=SONAR_PAGINATED_MAX_ATTEMPTS,
|
||||
retry_backoffs=SONAR_PAGINATED_RETRY_BACKOFF_SECONDS,
|
||||
)
|
||||
try:
|
||||
r_json = r.json()
|
||||
except requests.exceptions.JSONDecodeError as e:
|
||||
body_preview = r.text[:300].strip()
|
||||
raise RuntimeError(
|
||||
f"Sonar API at {graphql_url} returned non-JSON content "
|
||||
f"(status={r.status_code}, content-type={r.headers.get('content-type')}, body-preview={body_preview!r})"
|
||||
) from e
|
||||
|
||||
if 'errors' in r_json:
|
||||
raise RuntimeError(f"Sonar GraphQL error from {graphql_url}: {r_json['errors']}")
|
||||
if 'data' not in r_json:
|
||||
raise RuntimeError(f"Sonar API response from {graphql_url} missing 'data': {r_json}")
|
||||
|
||||
field = list(r_json['data'].keys())[0]
|
||||
connection = r_json['data'][field]
|
||||
entities.extend(connection['entities'])
|
||||
page_info = connection.get('page_info') or {}
|
||||
total_pages = page_info.get('total_pages', page)
|
||||
if page >= total_pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return entities
|
||||
|
||||
def getActiveStatuses():
|
||||
if not sonar_active_status_ids():
|
||||
query = """query getActiveStatuses {
|
||||
@@ -58,15 +204,139 @@ def getActiveStatuses():
|
||||
# Sometimes the IP will be under the field data for an item and sometimes it will be assigned to the inventory item itself.
|
||||
def findIPs(inventory_item):
|
||||
ips = []
|
||||
for ip in inventory_item['inventory_model_field_data']['entities'][0]['ip_assignments']['entities']:
|
||||
ips.append(ip['subnet'])
|
||||
for ip in inventory_item['ip_assignments']['entities']:
|
||||
for field_data in inventory_item.get('inventory_model_field_data', {}).get('entities', []):
|
||||
for ip in field_data.get('ip_assignments', {}).get('entities', []):
|
||||
ips.append(ip['subnet'])
|
||||
for ip in inventory_item.get('ip_assignments', {}).get('entities', []):
|
||||
ips.append(ip['subnet'])
|
||||
return ips
|
||||
|
||||
|
||||
def dedupeSubnets(subnets):
|
||||
deduped = []
|
||||
seen = set()
|
||||
for subnet in subnets:
|
||||
if not subnet or subnet in seen:
|
||||
continue
|
||||
seen.add(subnet)
|
||||
deduped.append(subnet)
|
||||
return deduped
|
||||
|
||||
|
||||
def normalizeServiceName(name):
|
||||
return (name or "").strip().casefold()
|
||||
|
||||
|
||||
def findRadiusAccountIPs(radius_account):
|
||||
ips = []
|
||||
for ip in radius_account.get('ip_assignments', {}).get('entities', []):
|
||||
subnet = ip.get('subnet')
|
||||
if subnet:
|
||||
ips.append(subnet)
|
||||
return dedupeSubnets(ips)
|
||||
|
||||
|
||||
def recurringServiceRateRules():
|
||||
rules = {}
|
||||
for enabled, service_name, download_mbps, upload_mbps in sonar_recurring_service_rates():
|
||||
normalized_name = normalizeServiceName(service_name)
|
||||
if not enabled or not normalized_name:
|
||||
continue
|
||||
rules[normalized_name] = (
|
||||
float(download_mbps),
|
||||
float(upload_mbps),
|
||||
)
|
||||
return rules
|
||||
|
||||
|
||||
def recurringServiceExclusions():
|
||||
return {
|
||||
normalizeServiceName(name)
|
||||
for name in sonar_recurring_excluded_service_names()
|
||||
if normalizeServiceName(name)
|
||||
}
|
||||
|
||||
|
||||
def resolveAccountBandwidth(account, recurring_rules=None, excluded_names=None):
|
||||
account_services = account.get('account_services', {}).get('entities', [])
|
||||
if account_services:
|
||||
service = account_services[0].get('service') or {}
|
||||
service_detail = service.get('data_service_detail')
|
||||
if service_detail:
|
||||
return (
|
||||
float(service_detail['download_speed_kilobits_per_second']) / 1000,
|
||||
float(service_detail['upload_speed_kilobits_per_second']) / 1000,
|
||||
)
|
||||
|
||||
if recurring_rules is None:
|
||||
recurring_rules = recurringServiceRateRules()
|
||||
if not recurring_rules:
|
||||
return None
|
||||
if excluded_names is None:
|
||||
excluded_names = recurringServiceExclusions()
|
||||
all_services = account.get('all_account_services', {}).get('entities', [])
|
||||
for row in all_services:
|
||||
service = row.get('service') or {}
|
||||
if service.get('type') != 'RECURRING':
|
||||
continue
|
||||
normalized_name = normalizeServiceName(service.get('name'))
|
||||
if not normalized_name or normalized_name in excluded_names:
|
||||
continue
|
||||
if normalized_name in recurring_rules:
|
||||
return recurring_rules[normalized_name]
|
||||
return None
|
||||
|
||||
|
||||
def findPrimaryMac(inventory_item):
|
||||
field_data_entities = inventory_item.get('inventory_model_field_data', {}).get('entities', [])
|
||||
|
||||
def mac_candidates(primary_only):
|
||||
for field_data in field_data_entities:
|
||||
field = field_data.get('inventory_model_field') or {}
|
||||
value = (field_data.get('value') or '').strip()
|
||||
field_name = (field.get('name') or '').strip().lower()
|
||||
if not value:
|
||||
continue
|
||||
if 'mac' not in field_name:
|
||||
continue
|
||||
if primary_only and not field.get('primary', False):
|
||||
continue
|
||||
yield value
|
||||
|
||||
for value in mac_candidates(primary_only=True):
|
||||
return value
|
||||
for value in mac_candidates(primary_only=False):
|
||||
return value
|
||||
for field_data in field_data_entities:
|
||||
value = (field_data.get('value') or '').strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def formatAccountAddress(account):
|
||||
addresses = account.get('addresses', {}).get('entities', [])
|
||||
if not addresses:
|
||||
return ""
|
||||
primary_address = addresses[0]
|
||||
line1 = primary_address.get('line1') or ""
|
||||
line2 = primary_address.get('line2') or ""
|
||||
city = primary_address.get('city') or ""
|
||||
subdivision = primary_address.get('subdivision') or ""
|
||||
state = subdivision[-2:] if subdivision else ""
|
||||
|
||||
address = f"{line1},{f' {line2},' if line2 else ''} {city}, {state}".strip()
|
||||
return address.strip(', ')
|
||||
|
||||
def getSitesAndAps():
|
||||
query = """query getSitesAndAps($pages: Paginator, $rr_ap_models: ReverseRelationFilter, $ap_models: Search){
|
||||
network_sites (paginator: $pages,reverse_relation_filters: [$rr_ap_models]) {
|
||||
page_info {
|
||||
page
|
||||
records_per_page
|
||||
total_pages
|
||||
total_count
|
||||
}
|
||||
entities {
|
||||
name
|
||||
id
|
||||
@@ -102,12 +372,7 @@ def getSitesAndAps():
|
||||
"search_value": ap_id
|
||||
})
|
||||
|
||||
variables = {"pages":
|
||||
{
|
||||
"records_per_page": 5,
|
||||
"page": 1
|
||||
},
|
||||
"rr_ap_models": {
|
||||
variables = {"rr_ap_models": {
|
||||
"relation": "inventory_items",
|
||||
"search": [{
|
||||
"integer_fields": search_aps
|
||||
@@ -118,7 +383,7 @@ def getSitesAndAps():
|
||||
}
|
||||
}
|
||||
|
||||
sites_and_aps = sonarRequest(query,variables)
|
||||
sites_and_aps = sonarPaginatedRequest(query,variables)
|
||||
# This should only return sites that have equipment on them that is in the list sonar_ubiquiti_ap_model_ids in lqos.conf
|
||||
sites = []
|
||||
aps = []
|
||||
@@ -134,9 +399,123 @@ def getSitesAndAps():
|
||||
sites.append({'id': f"site_{site['id']}", 'raw_id': site['id'], 'name': site['name']})
|
||||
return sites, aps
|
||||
|
||||
|
||||
def buildAccountDevices(account):
|
||||
devices = []
|
||||
known_ip_subnets = set()
|
||||
|
||||
for address in account.get('addresses', {}).get('entities', []):
|
||||
for item in address.get('inventory_items', {}).get('entities', []):
|
||||
ips = dedupeSubnets(findIPs(item))
|
||||
known_ip_subnets.update(ips)
|
||||
devices.append({
|
||||
'id': sonarDeviceNodeId(item['id']),
|
||||
'raw_id': item['id'],
|
||||
'name': item['inventory_model']['name'],
|
||||
'ips': ips,
|
||||
'mac': findPrimaryMac(item)
|
||||
})
|
||||
|
||||
for radius_account in account.get('radius_accounts', {}).get('entities', []):
|
||||
radius_ips = [
|
||||
subnet for subnet in findRadiusAccountIPs(radius_account)
|
||||
if subnet not in known_ip_subnets
|
||||
]
|
||||
if not radius_ips:
|
||||
continue
|
||||
known_ip_subnets.update(radius_ips)
|
||||
devices.append({
|
||||
'id': sonarRadiusAccountDeviceNodeId(radius_account['id']),
|
||||
'raw_id': radius_account['id'],
|
||||
'name': f"Radius Account {radius_account['id']}",
|
||||
'ips': radius_ips,
|
||||
'mac': ''
|
||||
})
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def buildAccountRecord(account, fallback_address="", recurring_rules=None, excluded_names=None):
|
||||
address = formatAccountAddress(account) or fallback_address
|
||||
if not address:
|
||||
return None
|
||||
|
||||
devices = buildAccountDevices(account)
|
||||
if not devices:
|
||||
return None
|
||||
|
||||
bandwidth = resolveAccountBandwidth(
|
||||
account,
|
||||
recurring_rules=recurring_rules,
|
||||
excluded_names=excluded_names,
|
||||
)
|
||||
if bandwidth is None:
|
||||
return None
|
||||
|
||||
download_raw, upload_raw = bandwidth
|
||||
download = apply_client_bandwidth_multiplier(download_raw)
|
||||
upload = apply_client_bandwidth_multiplier(upload_raw)
|
||||
if download < 2:
|
||||
download = 2
|
||||
if upload < 2:
|
||||
upload = 2
|
||||
|
||||
return {
|
||||
'id': sonarAccountNodeId(account['id']),
|
||||
'raw_id': account['id'],
|
||||
'name': account['name'],
|
||||
'address': address,
|
||||
'download': download,
|
||||
'upload': upload,
|
||||
'devices': devices
|
||||
}
|
||||
|
||||
|
||||
def buildAccountsFromSonarEntities(accounts_from_sonar, recurring_rules=None, excluded_names=None):
|
||||
accounts = []
|
||||
seen_account_ids = set()
|
||||
child_candidates = []
|
||||
|
||||
for account in accounts_from_sonar:
|
||||
account_record = buildAccountRecord(
|
||||
account,
|
||||
recurring_rules=recurring_rules,
|
||||
excluded_names=excluded_names,
|
||||
)
|
||||
if account_record:
|
||||
accounts.append(account_record)
|
||||
seen_account_ids.add(account_record['raw_id'])
|
||||
|
||||
parent_address = formatAccountAddress(account)
|
||||
for child_account in account.get('child_accounts', {}).get('entities', []):
|
||||
child_candidates.append((child_account, parent_address))
|
||||
|
||||
for child_account, parent_address in child_candidates:
|
||||
child_id = child_account['id']
|
||||
if child_id in seen_account_ids:
|
||||
continue
|
||||
child_record = buildAccountRecord(
|
||||
child_account,
|
||||
fallback_address=parent_address,
|
||||
recurring_rules=recurring_rules,
|
||||
excluded_names=excluded_names,
|
||||
)
|
||||
if child_record:
|
||||
accounts.append(child_record)
|
||||
seen_account_ids.add(child_record['raw_id'])
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def getAccounts(sonar_active_status_ids):
|
||||
query = """query getAccounts ($pages: Paginator, $account_search: Search, $data: ReverseRelationFilter,$primary: ReverseRelationFilter) {
|
||||
query = """query getAccounts ($pages: Paginator, $account_search: Search, $data: ReverseRelationFilter) {
|
||||
accounts (paginator: $pages,search: [$account_search]) {
|
||||
page_info {
|
||||
page
|
||||
records_per_page
|
||||
total_pages
|
||||
total_count
|
||||
}
|
||||
entities {
|
||||
account_status_id
|
||||
id
|
||||
@@ -163,7 +542,7 @@ def getAccounts(sonar_active_status_ids):
|
||||
inventory_model {
|
||||
name
|
||||
}
|
||||
inventory_model_field_data (reverse_relation_filters: [$primary]) {
|
||||
inventory_model_field_data {
|
||||
entities {
|
||||
value
|
||||
ip_assignments {
|
||||
@@ -171,6 +550,11 @@ def getAccounts(sonar_active_status_ids):
|
||||
subnet
|
||||
}
|
||||
}
|
||||
inventory_model_field {
|
||||
id
|
||||
name
|
||||
primary
|
||||
}
|
||||
}
|
||||
}
|
||||
ip_assignments {
|
||||
@@ -182,24 +566,119 @@ def getAccounts(sonar_active_status_ids):
|
||||
}
|
||||
}
|
||||
}
|
||||
radius_accounts {
|
||||
entities {
|
||||
id
|
||||
ip_assignments {
|
||||
entities {
|
||||
subnet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
child_accounts {
|
||||
entities {
|
||||
id
|
||||
name
|
||||
account_services (reverse_relation_filters: [$data]) {
|
||||
entities {
|
||||
service {
|
||||
data_service_detail {
|
||||
download_speed_kilobits_per_second
|
||||
upload_speed_kilobits_per_second
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
addresses {
|
||||
entities {
|
||||
line1
|
||||
line2
|
||||
city
|
||||
subdivision
|
||||
inventory_items {
|
||||
entities {
|
||||
id
|
||||
inventory_model {
|
||||
name
|
||||
}
|
||||
inventory_model_field_data {
|
||||
entities {
|
||||
value
|
||||
ip_assignments {
|
||||
entities {
|
||||
subnet
|
||||
}
|
||||
}
|
||||
inventory_model_field {
|
||||
id
|
||||
name
|
||||
primary
|
||||
}
|
||||
}
|
||||
}
|
||||
ip_assignments {
|
||||
entities {
|
||||
subnet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
radius_accounts {
|
||||
entities {
|
||||
id
|
||||
ip_assignments {
|
||||
entities {
|
||||
subnet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
all_account_services: account_services {
|
||||
entities {
|
||||
service(enabled: true) {
|
||||
id
|
||||
name
|
||||
type
|
||||
enabled
|
||||
data_service_detail {
|
||||
download_speed_kilobits_per_second
|
||||
upload_speed_kilobits_per_second
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
all_account_services: account_services {
|
||||
entities {
|
||||
service(enabled: true) {
|
||||
id
|
||||
name
|
||||
type
|
||||
enabled
|
||||
data_service_detail {
|
||||
download_speed_kilobits_per_second
|
||||
upload_speed_kilobits_per_second
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
active_status_ids = []
|
||||
for status_id in sonar_active_status_ids():
|
||||
for status_id in sonar_active_status_ids:
|
||||
active_status_ids.append({
|
||||
"attribute": "account_status_id",
|
||||
"operator": "EQ",
|
||||
"search_value": status_id
|
||||
})
|
||||
|
||||
variables = {"pages":
|
||||
{
|
||||
"records_per_page": 5,
|
||||
"page": 1
|
||||
},
|
||||
"account_search": {
|
||||
variables = {"account_search": {
|
||||
"integer_fields": active_status_ids
|
||||
},
|
||||
"data": {
|
||||
@@ -211,40 +690,17 @@ def getAccounts(sonar_active_status_ids):
|
||||
"search_value": "DATA"
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"primary": {
|
||||
"relation": "inventory_model_field",
|
||||
"search": [{
|
||||
"boolean_fields": [{
|
||||
"attribute": "primary",
|
||||
"search_value": True
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accounts_from_sonar = sonarRequest(query,variables)
|
||||
accounts = []
|
||||
for account in accounts_from_sonar:
|
||||
# We need to make sure the account has an address because Sonar assignments go account -> address (only 1 per account) -> equipment -> ip assignments unless the IP is assigned to the account directly.
|
||||
if account['addresses']['entities']:
|
||||
line1 = account['addresses']['entities'][0]['line1']
|
||||
line2 = account['addresses']['entities'][0]['line2']
|
||||
city = account['addresses']['entities'][0]['city']
|
||||
state = account['addresses']['entities'][0]['subdivision'][-2:]
|
||||
address = f"{line1},{f' {line2},' if line2 else ''} {city}, {state}"
|
||||
devices = []
|
||||
for item in account['addresses']['entities'][0]['inventory_items']['entities']:
|
||||
devices.append({'id': item['id'], 'name': item['inventory_model']['name'], 'ips': findIPs(item), 'mac': item['inventory_model_field_data']['entities'][0]['value']})
|
||||
if account['account_services']['entities'] and devices: # Make sure there is a data plan and devices on the account.
|
||||
download = apply_client_bandwidth_multiplier(float(account['account_services']['entities'][0]['service']['data_service_detail']['download_speed_kilobits_per_second'])/1000)
|
||||
upload = apply_client_bandwidth_multiplier(float(account['account_services']['entities'][0]['service']['data_service_detail']['upload_speed_kilobits_per_second'])/1000)
|
||||
if download < 2:
|
||||
download = 2
|
||||
if upload < 2:
|
||||
upload = 2
|
||||
accounts.append({'id': account['id'],'name': account['name'], 'address': address, 'download': download, 'upload': upload ,'devices': devices})
|
||||
return accounts
|
||||
accounts_from_sonar = sonarPaginatedRequest(query,variables)
|
||||
recurring_rules = recurringServiceRateRules()
|
||||
excluded_names = recurringServiceExclusions() if recurring_rules else set()
|
||||
return buildAccountsFromSonarEntities(
|
||||
accounts_from_sonar,
|
||||
recurring_rules=recurring_rules,
|
||||
excluded_names=excluded_names,
|
||||
)
|
||||
|
||||
def mapApCpeMacs(ap):
|
||||
macs = []
|
||||
@@ -266,12 +722,12 @@ def mapApCpeMacs(ap):
|
||||
|
||||
return ap
|
||||
|
||||
def mapMacAP(mac,aps):
|
||||
def buildMacToApMap(aps):
|
||||
mac_to_ap = {}
|
||||
for ap in aps:
|
||||
for cpe_mac in ap['cpe_macs']:
|
||||
if cpe_mac == mac:
|
||||
return ap['id']
|
||||
return None
|
||||
mac_to_ap[cpe_mac] = ap['id']
|
||||
return mac_to_ap
|
||||
|
||||
def createShaper():
|
||||
net = NetworkGraph()
|
||||
@@ -292,10 +748,12 @@ def createShaper():
|
||||
pool.close()
|
||||
pool.join()
|
||||
|
||||
mac_to_ap = buildMacToApMap(aps)
|
||||
|
||||
# Update customers with the AP to which they are connected.
|
||||
for account in accounts:
|
||||
for device in account['devices']:
|
||||
account['parent'] = mapMacAP(device['mac'],aps)
|
||||
account['parent'] = mac_to_ap.get(device['mac'])
|
||||
if account['parent']:
|
||||
break
|
||||
|
||||
|
||||
+3
-1
@@ -35,7 +35,7 @@ to_network = "eth1"
|
||||
# Per-circuit overrides in ShapedDevices.csv may specify directional tokens
|
||||
# as "down_sqm/up_sqm" (see ShapedDevices.example.csv and INTEGRATION_HOWTO.md).
|
||||
default_sqm = "cake diffserv4"
|
||||
monitor_only = false
|
||||
queue_mode = "shape"
|
||||
uplink_bandwidth_mbps = 100000
|
||||
downlink_bandwidth_mbps = 100000
|
||||
generated_pn_download_mbps = 100000
|
||||
@@ -120,6 +120,8 @@ snmp_community = "public"
|
||||
airmax_model_ids = [ "" ]
|
||||
ltu_model_ids = [ "" ]
|
||||
active_status_ids = [ "" ]
|
||||
recurring_excluded_service_names = []
|
||||
recurring_service_rates = []
|
||||
|
||||
[influxdb]
|
||||
enable_influxdb = false
|
||||
|
||||
Generated
+69
-8
@@ -182,6 +182,18 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -207,9 +219,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.4"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -217,9 +229,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -315,6 +327,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
@@ -356,6 +374,15 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1027,6 +1054,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1462,6 +1490,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -1989,7 +2026,10 @@ dependencies = [
|
||||
"lqos_config",
|
||||
"lqos_sys",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -2016,6 +2056,7 @@ dependencies = [
|
||||
"allocative",
|
||||
"allocative_derive",
|
||||
"arc-swap",
|
||||
"argon2",
|
||||
"csv",
|
||||
"encoding_rs",
|
||||
"ip_network",
|
||||
@@ -2023,6 +2064,7 @@ dependencies = [
|
||||
"lqos_utils",
|
||||
"nix",
|
||||
"once_cell",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -2075,9 +2117,11 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"lqos_bakery",
|
||||
"lqos_bus",
|
||||
"lqos_config",
|
||||
"lqos_overrides",
|
||||
"lqos_sys",
|
||||
"lqos_utils",
|
||||
"miniz_oxide",
|
||||
"nix",
|
||||
@@ -2099,6 +2143,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"criterion",
|
||||
"dashmap",
|
||||
"lqos_bakery",
|
||||
"lqos_bus",
|
||||
"lqos_config",
|
||||
"lqos_sys",
|
||||
@@ -2199,6 +2244,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"bincode",
|
||||
"crossbeam-channel",
|
||||
"crossbeam-queue",
|
||||
@@ -2208,6 +2254,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"fxhash",
|
||||
"hmac",
|
||||
"ip_network",
|
||||
"ip_network_table",
|
||||
"itertools 0.13.0",
|
||||
@@ -2230,6 +2277,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_cbor",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha256",
|
||||
"signal-hook",
|
||||
"smallvec",
|
||||
@@ -2270,7 +2318,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"lqos_bus",
|
||||
"lqos_config",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2739,6 +2789,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -3233,9 +3294,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3297,9 +3358,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
@@ -54,6 +54,10 @@ csv = "1"
|
||||
ip_network_table = "0"
|
||||
ip_network = "0"
|
||||
sha2 = "0"
|
||||
hmac = "0.12"
|
||||
base64 = "0.22"
|
||||
argon2 = "0.5"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
uuid = { version = "1", features = ["v4", "fast-rng" ] }
|
||||
dashmap = "^5.5.3"
|
||||
toml = "0.8.8"
|
||||
|
||||
@@ -13,3 +13,8 @@ crossbeam-channel.workspace = true
|
||||
allocative.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
toml.workspace = true
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::MQ_CREATED;
|
||||
use crate::qdisc_handles::{InfraQdiscSlot, infra_qdisc_handle};
|
||||
use crate::queue_math::{
|
||||
format_rate_for_tc, format_rate_for_tc_f32, quantum, r2q, sqm_as_vec, sqm_tokens_for,
|
||||
};
|
||||
@@ -7,8 +8,71 @@ use lqos_bus::TcHandle;
|
||||
use lqos_config::LazyQueueMode;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender as ReplySender;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Runtime TreeGuard node-operation action tracked by Bakery.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Allocative)]
|
||||
pub enum RuntimeNodeOperationAction {
|
||||
/// Virtualize the target node/subtree.
|
||||
Virtualize,
|
||||
/// Restore the target node/subtree to the physical tree.
|
||||
Restore,
|
||||
}
|
||||
|
||||
/// Runtime TreeGuard node-operation status tracked by Bakery.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Allocative)]
|
||||
pub enum RuntimeNodeOperationStatus {
|
||||
/// Operation was accepted but has not started applying yet.
|
||||
Submitted,
|
||||
/// Operation was not started because Bakery runtime-node capacity is currently saturated.
|
||||
Deferred,
|
||||
/// Operation is currently applying reparent/restore work.
|
||||
Applying,
|
||||
/// Traffic-carrying work succeeded; cleanup is still pending.
|
||||
AppliedAwaitingCleanup,
|
||||
/// Operation completed successfully.
|
||||
Completed,
|
||||
/// Operation failed and can be retried.
|
||||
Failed,
|
||||
/// Operation could not be reconciled safely and the subtree is now frozen.
|
||||
Dirty,
|
||||
}
|
||||
|
||||
/// Structured failure reason for a Bakery-tracked TreeGuard runtime node operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Allocative)]
|
||||
pub enum RuntimeNodeOperationFailureReason {
|
||||
/// The node has no direct child sites or direct circuits to promote.
|
||||
StructuralIneligibleNoPromotableChildren,
|
||||
/// The node has only one promotable direct child, so v1 flattening has no deterministic split.
|
||||
StructuralIneligibleSinglePromotableChild,
|
||||
}
|
||||
|
||||
/// Snapshot of a Bakery-tracked TreeGuard runtime node operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Allocative)]
|
||||
pub struct RuntimeNodeOperationSnapshot {
|
||||
/// Monotonic Bakery-local operation identifier.
|
||||
pub operation_id: u64,
|
||||
/// Stable Bakery site hash derived from the node name.
|
||||
pub site_hash: i64,
|
||||
/// Requested runtime action.
|
||||
pub action: RuntimeNodeOperationAction,
|
||||
/// Current operation status.
|
||||
pub status: RuntimeNodeOperationStatus,
|
||||
/// Number of retry/apply attempts performed so far.
|
||||
pub attempt_count: u32,
|
||||
/// Unix timestamp when the operation was submitted.
|
||||
pub submitted_at_unix: u64,
|
||||
/// Unix timestamp when the operation last changed state.
|
||||
pub updated_at_unix: u64,
|
||||
/// Optional unix timestamp for the next retry, if waiting.
|
||||
pub next_retry_at_unix: Option<u64>,
|
||||
/// Last error observed by Bakery for this operation, if any.
|
||||
pub last_error: Option<String>,
|
||||
/// Structured failure reason observed by Bakery for this operation, if any.
|
||||
pub failure_reason: Option<RuntimeNodeOperationFailureReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Allocative)]
|
||||
struct AddSiteParams {
|
||||
site_hash: i64,
|
||||
@@ -33,6 +97,8 @@ struct AddCircuitParams {
|
||||
upload_bandwidth_max: f32,
|
||||
class_major: u16,
|
||||
up_class_major: u16,
|
||||
down_qdisc_handle: Option<u16>,
|
||||
up_qdisc_handle: Option<u16>,
|
||||
// Optional per-circuit SQM override: "cake" or "fq_codel"
|
||||
sqm_override: Option<String>,
|
||||
}
|
||||
@@ -131,6 +197,10 @@ pub enum BakeryCommands {
|
||||
AddCircuit {
|
||||
/// Unique identifier for the circuit.
|
||||
circuit_hash: i64,
|
||||
/// Human-readable circuit name when known at queue-build time.
|
||||
circuit_name: Option<String>,
|
||||
/// Human-readable parent site/node name when known at queue-build time.
|
||||
site_name: Option<String>,
|
||||
/// Parent class handle on the ISP-facing interface (downlink side).
|
||||
parent_class_id: TcHandle,
|
||||
/// Parent class handle on the Internet-facing interface (uplink side).
|
||||
@@ -149,6 +219,10 @@ pub enum BakeryCommands {
|
||||
class_major: u16,
|
||||
/// Major class ID (uplink) used when attaching SQM/HTB.
|
||||
up_class_major: u16,
|
||||
/// Explicit qdisc handle major for the downlink leaf qdisc, if assigned.
|
||||
down_qdisc_handle: Option<u16>,
|
||||
/// Explicit qdisc handle major for the uplink leaf qdisc, if assigned.
|
||||
up_qdisc_handle: Option<u16>,
|
||||
/// Concatenated list of all IPs for this circuit.
|
||||
ip_addresses: String, // Concatenated list of all IPs for this circuit
|
||||
/// Optional per-circuit SQM override: "cake" or "fq_codel"
|
||||
@@ -165,6 +239,16 @@ pub enum BakeryCommands {
|
||||
/// New class ceiling rate in Mbps (the handler sets ceil and rate-1).
|
||||
new_rate: u64,
|
||||
},
|
||||
/// Runtime TreeGuard request to virtualize or restore a non-top-level site without a full reload.
|
||||
TreeGuardSetNodeVirtual {
|
||||
/// Stable Bakery site hash derived from the node name.
|
||||
site_hash: i64,
|
||||
/// Whether the site should be virtualized (`true`) or restored (`false`).
|
||||
virtualized: bool,
|
||||
/// Optional synchronous reply channel for immediate operation-state reporting.
|
||||
#[allocative(skip)]
|
||||
reply: Option<ReplySender<RuntimeNodeOperationSnapshot>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl BakeryCommands {
|
||||
@@ -217,6 +301,8 @@ impl BakeryCommands {
|
||||
),
|
||||
BakeryCommands::AddCircuit {
|
||||
circuit_hash,
|
||||
circuit_name: _,
|
||||
site_name: _,
|
||||
parent_class_id,
|
||||
up_parent_class_id,
|
||||
class_minor,
|
||||
@@ -226,6 +312,8 @@ impl BakeryCommands {
|
||||
upload_bandwidth_max,
|
||||
class_major,
|
||||
up_class_major,
|
||||
down_qdisc_handle,
|
||||
up_qdisc_handle,
|
||||
ip_addresses: _,
|
||||
sqm_override,
|
||||
} => Self::add_circuit(
|
||||
@@ -242,6 +330,8 @@ impl BakeryCommands {
|
||||
upload_bandwidth_max: *upload_bandwidth_max,
|
||||
class_major: *class_major,
|
||||
up_class_major: *up_class_major,
|
||||
down_qdisc_handle: *down_qdisc_handle,
|
||||
up_qdisc_handle: *up_qdisc_handle,
|
||||
sqm_override: sqm_override.clone(),
|
||||
},
|
||||
),
|
||||
@@ -255,38 +345,27 @@ impl BakeryCommands {
|
||||
stick_offset: usize,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
let mut result = Vec::new();
|
||||
info!("Clearing prior settings");
|
||||
if config.on_a_stick_mode() {
|
||||
// Clear just the MQ on the ISP-facing interface
|
||||
result.push(vec![
|
||||
"qdisc".to_string(),
|
||||
"del".to_string(),
|
||||
"dev".to_string(),
|
||||
config.isp_interface(),
|
||||
"root".to_string(),
|
||||
]);
|
||||
} else {
|
||||
result.push(vec![
|
||||
"qdisc".to_string(),
|
||||
"del".to_string(),
|
||||
"dev".to_string(),
|
||||
config.isp_interface(),
|
||||
"root".to_string(),
|
||||
]);
|
||||
result.push(vec![
|
||||
"qdisc".to_string(),
|
||||
"del".to_string(),
|
||||
"dev".to_string(),
|
||||
config.internet_interface(),
|
||||
"root".to_string(),
|
||||
]);
|
||||
}
|
||||
let observe_only = config.queues.queue_mode.is_observe();
|
||||
let mq_already_created = MQ_CREATED.load(std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
if observe_only {
|
||||
if !mq_already_created {
|
||||
result.push(root_mq_add_command(config.isp_interface()));
|
||||
if !config.on_a_stick_mode() {
|
||||
result.push(root_mq_add_command(config.internet_interface()));
|
||||
}
|
||||
}
|
||||
MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
return Some(result);
|
||||
}
|
||||
info!(
|
||||
"Setting up MQ with {} queues and stick offset {}",
|
||||
queues_available, stick_offset
|
||||
);
|
||||
// command = 'qdisc replace dev ' + thisInterface + ' root handle 7FFF: mq'
|
||||
// Root MQ preflight is handled outside the strict builder batch during
|
||||
// full reloads. The builder path only bootstraps root mq when runtime
|
||||
// state is empty and otherwise rebuilds child HTB/SQM state beneath
|
||||
// the prepared root.
|
||||
let sqm_strings = sqm_as_vec(config);
|
||||
let r2q = r2q(u64::max(
|
||||
config.queues.uplink_bandwidth_mbps,
|
||||
@@ -294,16 +373,9 @@ impl BakeryCommands {
|
||||
));
|
||||
|
||||
// ISP-facing interface (interface_a in Python)
|
||||
result.push(vec![
|
||||
"qdisc".to_string(),
|
||||
"replace".to_string(),
|
||||
"dev".to_string(),
|
||||
config.isp_interface(),
|
||||
"root".to_string(),
|
||||
"handle".to_string(),
|
||||
"7FFF:".to_string(),
|
||||
"mq".to_string(),
|
||||
]);
|
||||
if !mq_already_created {
|
||||
result.push(root_mq_add_command(config.isp_interface()));
|
||||
}
|
||||
|
||||
/*
|
||||
for queue in range(queuesAvailable):
|
||||
@@ -364,6 +436,11 @@ impl BakeryCommands {
|
||||
config.isp_interface(),
|
||||
"parent".to_string(),
|
||||
format!("0x{:x}:1", queue + 1),
|
||||
"handle".to_string(),
|
||||
format!(
|
||||
"0x{:x}:",
|
||||
infra_qdisc_handle((queue + 1) as u16, InfraQdiscSlot::Primary)
|
||||
),
|
||||
];
|
||||
class.extend(sqm_strings.clone());
|
||||
result.push(class);
|
||||
@@ -401,23 +478,19 @@ impl BakeryCommands {
|
||||
config.isp_interface(),
|
||||
"parent".to_string(),
|
||||
format!("0x{:x}:2", queue + 1),
|
||||
"handle".to_string(),
|
||||
format!(
|
||||
"0x{:x}:",
|
||||
infra_qdisc_handle((queue + 1) as u16, InfraQdiscSlot::Default)
|
||||
),
|
||||
];
|
||||
default_class.extend(sqm_strings.clone());
|
||||
result.push(default_class);
|
||||
}
|
||||
|
||||
// Internet-facing interface (interface_b in Python)
|
||||
if !config.on_a_stick_mode() {
|
||||
result.push(vec![
|
||||
"qdisc".to_string(),
|
||||
"replace".to_string(),
|
||||
"dev".to_string(),
|
||||
config.internet_interface(),
|
||||
"root".to_string(),
|
||||
"handle".to_string(),
|
||||
"7FFF:".to_string(),
|
||||
"mq".to_string(),
|
||||
]);
|
||||
if !config.on_a_stick_mode() && !mq_already_created {
|
||||
result.push(root_mq_add_command(config.internet_interface()));
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -478,6 +551,11 @@ impl BakeryCommands {
|
||||
config.internet_interface(),
|
||||
"parent".to_string(),
|
||||
format!("0x{:x}:1", queue + stick_offset + 1),
|
||||
"handle".to_string(),
|
||||
format!(
|
||||
"0x{:x}:",
|
||||
infra_qdisc_handle((queue + stick_offset + 1) as u16, InfraQdiscSlot::Primary)
|
||||
),
|
||||
];
|
||||
class.extend(sqm_strings.clone());
|
||||
result.push(class);
|
||||
@@ -514,6 +592,11 @@ impl BakeryCommands {
|
||||
config.internet_interface(),
|
||||
"parent".to_string(),
|
||||
format!("0x{:x}:2", queue + stick_offset + 1),
|
||||
"handle".to_string(),
|
||||
format!(
|
||||
"0x{:x}:",
|
||||
infra_qdisc_handle((queue + stick_offset + 1) as u16, InfraQdiscSlot::Default)
|
||||
),
|
||||
];
|
||||
default_class.extend(sqm_strings.clone());
|
||||
result.push(default_class);
|
||||
@@ -527,6 +610,10 @@ impl BakeryCommands {
|
||||
config: &Arc<lqos_config::Config>,
|
||||
params: AddSiteParams,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
if config.queues.queue_mode.is_observe() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
// Derive major IDs from parent handles so classids are fully qualified
|
||||
// and consistent with queuingStructure.json (classMajor/classMinor).
|
||||
@@ -597,6 +684,10 @@ impl BakeryCommands {
|
||||
config: &Arc<lqos_config::Config>,
|
||||
params: AddCircuitParams,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
if config.queues.queue_mode.is_observe() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ref s) = params.sqm_override
|
||||
&& s.eq_ignore_ascii_case("fq_codel")
|
||||
{
|
||||
@@ -706,7 +797,7 @@ impl BakeryCommands {
|
||||
),
|
||||
]);
|
||||
}
|
||||
if !config.queues.monitor_only
|
||||
if !config.queues.queue_mode.is_observe()
|
||||
&& do_sqm
|
||||
&& !matches!(down_override_opt.as_deref(), Some(s) if s.eq_ignore_ascii_case("none"))
|
||||
{
|
||||
@@ -718,6 +809,10 @@ impl BakeryCommands {
|
||||
"parent".to_string(),
|
||||
format!("0x{:x}:0x{:x}", params.class_major, params.class_minor),
|
||||
];
|
||||
if let Some(handle) = params.down_qdisc_handle {
|
||||
sqm_command.push("handle".to_string());
|
||||
sqm_command.push(format!("0x{:x}:", handle));
|
||||
}
|
||||
sqm_command.extend(sqm_tokens_for(
|
||||
params.download_bandwidth_max,
|
||||
config,
|
||||
@@ -753,7 +848,7 @@ impl BakeryCommands {
|
||||
]);
|
||||
}
|
||||
|
||||
if !config.queues.monitor_only
|
||||
if !config.queues.queue_mode.is_observe()
|
||||
&& do_sqm
|
||||
&& !config.on_a_stick_mode()
|
||||
&& !matches!(up_override_opt.as_deref(), Some(s) if s.eq_ignore_ascii_case("none"))
|
||||
@@ -766,6 +861,10 @@ impl BakeryCommands {
|
||||
"parent".to_string(),
|
||||
format!("0x{:x}:0x{:x}", params.up_class_major, params.class_minor),
|
||||
];
|
||||
if let Some(handle) = params.up_qdisc_handle {
|
||||
sqm_command.push("handle".to_string());
|
||||
sqm_command.push(format!("0x{:x}:", handle));
|
||||
}
|
||||
sqm_command.extend(sqm_tokens_for(
|
||||
params.upload_bandwidth_max,
|
||||
config,
|
||||
@@ -923,3 +1022,331 @@ impl BakeryCommands {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn root_mq_add_command(interface_name: String) -> Vec<String> {
|
||||
vec![
|
||||
"qdisc".to_string(),
|
||||
"add".to_string(),
|
||||
"dev".to_string(),
|
||||
interface_name,
|
||||
"root".to_string(),
|
||||
"handle".to_string(),
|
||||
"7FFF:".to_string(),
|
||||
"mq".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BakeryCommands, ExecutionMode};
|
||||
use crate::MQ_CREATED;
|
||||
use crate::test_state_lock;
|
||||
use lqos_config::{Config, LazyQueueMode, SingleInterfaceConfig};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn is_root_delete(cmd: &[String], interface: &str) -> bool {
|
||||
cmd.len() == 5
|
||||
&& cmd[0] == "qdisc"
|
||||
&& cmd[1] == "del"
|
||||
&& cmd[2] == "dev"
|
||||
&& cmd[3] == interface
|
||||
&& cmd[4] == "root"
|
||||
}
|
||||
|
||||
fn is_root_add_mq(cmd: &[String], interface: &str) -> bool {
|
||||
cmd.len() == 8
|
||||
&& cmd[0] == "qdisc"
|
||||
&& cmd[1] == "add"
|
||||
&& cmd[2] == "dev"
|
||||
&& cmd[3] == interface
|
||||
&& cmd[4] == "root"
|
||||
&& cmd[5] == "handle"
|
||||
&& cmd[6] == "7FFF:"
|
||||
&& cmd[7] == "mq"
|
||||
}
|
||||
|
||||
fn assert_qdisc_add_replace_commands_use_explicit_handles(commands: &[Vec<String>]) {
|
||||
for cmd in commands {
|
||||
let is_qdisc_add_or_replace = cmd.first().is_some_and(|part| part == "qdisc")
|
||||
&& cmd
|
||||
.get(1)
|
||||
.is_some_and(|part| part == "add" || part == "replace");
|
||||
if !is_qdisc_add_or_replace {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
cmd.iter().any(|part| part == "handle"),
|
||||
"qdisc add/replace command omitted explicit handle: {:?}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_bridge_mode_bootstraps_root_add_commands_when_runtime_state_is_empty() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let config = Arc::new(Config::default());
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 1,
|
||||
stick_offset: 0,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands");
|
||||
|
||||
assert!(is_root_add_mq(&commands[0], &config.isp_interface()));
|
||||
let internet_add_idx = commands
|
||||
.iter()
|
||||
.position(|cmd| is_root_add_mq(cmd, &config.internet_interface()))
|
||||
.expect("expected internet-interface root add");
|
||||
assert!(
|
||||
internet_add_idx > 0,
|
||||
"internet-interface root add should occur after the ISP-interface root add"
|
||||
);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_on_a_stick_emits_single_root_add_when_runtime_state_is_empty() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let config = Arc::new(Config {
|
||||
bridge: None,
|
||||
single_interface: Some(SingleInterfaceConfig::default()),
|
||||
..Config::default()
|
||||
});
|
||||
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 1,
|
||||
stick_offset: 3,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands");
|
||||
|
||||
assert!(is_root_add_mq(&commands[0], &config.isp_interface()));
|
||||
assert_eq!(
|
||||
commands
|
||||
.iter()
|
||||
.filter(|cmd| is_root_add_mq(cmd, &config.isp_interface()))
|
||||
.count(),
|
||||
1,
|
||||
"expected a single root add on the shared interface"
|
||||
);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_shape_mode_skips_root_add_when_runtime_state_is_ready() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let config = Arc::new(Config::default());
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 1,
|
||||
stick_offset: 0,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands");
|
||||
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_add_mq(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_add_mq(cmd, &config.internet_interface()))
|
||||
);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_qdisc_add_replace_commands_use_explicit_handles() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let config = Arc::new(Config::default());
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 2,
|
||||
stick_offset: 0,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands");
|
||||
|
||||
assert_qdisc_add_replace_commands_use_explicit_handles(&commands);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_observe_mode_bootstraps_root_mq_when_runtime_state_is_empty() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
let mut cfg = Config::default();
|
||||
cfg.queues.set_queue_mode(lqos_config::QueueMode::Observe);
|
||||
let config = Arc::new(cfg);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 1,
|
||||
stick_offset: 0,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands in observe mode");
|
||||
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_delete(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_delete(cmd, &config.internet_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.any(|cmd| is_root_add_mq(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.any(|cmd| is_root_add_mq(cmd, &config.internet_interface()))
|
||||
);
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
2,
|
||||
"observe mode should bootstrap root mq when runtime state is empty"
|
||||
);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_observe_mode_reuses_retained_root_when_mq_exists() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
let mut cfg = Config::default();
|
||||
cfg.queues.set_queue_mode(lqos_config::QueueMode::Observe);
|
||||
let config = Arc::new(cfg);
|
||||
MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 2,
|
||||
stick_offset: 0,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands in observe mode");
|
||||
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_delete(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_delete(cmd, &config.internet_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_add_mq(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_add_mq(cmd, &config.internet_interface()))
|
||||
);
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
0,
|
||||
"observe mode should rely on full-reload preflight when the root mq already exists"
|
||||
);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_circuit_qdisc_replace_commands_use_explicit_handles_when_enriched() {
|
||||
let config = Arc::new(Config::default());
|
||||
let builder_commands = BakeryCommands::AddCircuit {
|
||||
circuit_hash: 42,
|
||||
circuit_name: None,
|
||||
site_name: None,
|
||||
parent_class_id: crate::TcHandle::from_u32(0x10020),
|
||||
up_parent_class_id: crate::TcHandle::from_u32(0x20020),
|
||||
class_minor: 0x21,
|
||||
download_bandwidth_min: 10.0,
|
||||
upload_bandwidth_min: 10.0,
|
||||
download_bandwidth_max: 100.0,
|
||||
upload_bandwidth_max: 100.0,
|
||||
class_major: 0x1,
|
||||
up_class_major: 0x2,
|
||||
down_qdisc_handle: Some(0x9000),
|
||||
up_qdisc_handle: Some(0x9001),
|
||||
ip_addresses: "192.0.2.42/32".to_string(),
|
||||
sqm_override: None,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("builder add_circuit should emit commands");
|
||||
assert_qdisc_add_replace_commands_use_explicit_handles(&builder_commands);
|
||||
|
||||
let mut live_cfg = Config::default();
|
||||
live_cfg.queues.lazy_queues = Some(LazyQueueMode::Full);
|
||||
let live_config = Arc::new(live_cfg);
|
||||
let live_commands = BakeryCommands::AddCircuit {
|
||||
circuit_hash: 42,
|
||||
circuit_name: None,
|
||||
site_name: None,
|
||||
parent_class_id: crate::TcHandle::from_u32(0x10020),
|
||||
up_parent_class_id: crate::TcHandle::from_u32(0x20020),
|
||||
class_minor: 0x21,
|
||||
download_bandwidth_min: 10.0,
|
||||
upload_bandwidth_min: 10.0,
|
||||
download_bandwidth_max: 100.0,
|
||||
upload_bandwidth_max: 100.0,
|
||||
class_major: 0x1,
|
||||
up_class_major: 0x2,
|
||||
down_qdisc_handle: Some(0x9000),
|
||||
up_qdisc_handle: Some(0x9001),
|
||||
ip_addresses: "192.0.2.42/32".to_string(),
|
||||
sqm_override: None,
|
||||
}
|
||||
.to_commands(&live_config, ExecutionMode::LiveUpdate)
|
||||
.expect("live add_circuit should emit commands");
|
||||
assert_qdisc_add_replace_commands_use_explicit_handles(&live_commands);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_setup_observe_mode_reuses_retained_root_on_a_stick() {
|
||||
let _guard = test_state_lock().lock().expect("lock");
|
||||
let mut cfg = Config {
|
||||
bridge: None,
|
||||
single_interface: Some(SingleInterfaceConfig::default()),
|
||||
..Config::default()
|
||||
};
|
||||
cfg.queues.set_queue_mode(lqos_config::QueueMode::Observe);
|
||||
let config = Arc::new(cfg);
|
||||
MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let commands = BakeryCommands::MqSetup {
|
||||
queues_available: 2,
|
||||
stick_offset: 2,
|
||||
}
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
.expect("mq setup should emit commands in observe on-a-stick mode");
|
||||
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_delete(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert!(
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| !is_root_add_mq(cmd, &config.isp_interface()))
|
||||
);
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
0,
|
||||
"observe mode should rely on full-reload preflight for retained on-a-stick roots"
|
||||
);
|
||||
MQ_CREATED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub(crate) struct CircuitDiffCategories<'a> {
|
||||
pub removed_circuits: Vec<i64>,
|
||||
pub speed_changed: Vec<&'a Arc<BakeryCommands>>,
|
||||
pub ip_changed: Vec<&'a Arc<BakeryCommands>>,
|
||||
pub migrated: Vec<&'a Arc<BakeryCommands>>,
|
||||
pub structural_changed: Vec<&'a Arc<BakeryCommands>>,
|
||||
}
|
||||
|
||||
@@ -35,7 +36,8 @@ pub(crate) fn diff_circuits<'a>(
|
||||
let mut removed_circuits = Vec::new();
|
||||
let mut speed_changed = Vec::new();
|
||||
let mut ip_changed = Vec::new();
|
||||
let mut structural_changed = Vec::new();
|
||||
let mut migrated = Vec::new();
|
||||
let structural_changed = Vec::new();
|
||||
|
||||
for (circuit_hash, new_cmd) in &new_circuits {
|
||||
if !old_circuits.contains_key(circuit_hash) {
|
||||
@@ -53,7 +55,7 @@ pub(crate) fn diff_circuits<'a>(
|
||||
if let Some(new_cmd) = new_circuits.get(circuit_hash) {
|
||||
match classify_circuit_change(old_cmd.as_ref(), new_cmd.as_ref()) {
|
||||
CircuitChange::None => {}
|
||||
CircuitChange::Structural => structural_changed.push(*new_cmd),
|
||||
CircuitChange::Migration => migrated.push(*new_cmd),
|
||||
CircuitChange::Speed => speed_changed.push(*new_cmd),
|
||||
CircuitChange::Ip => ip_changed.push(*new_cmd),
|
||||
CircuitChange::SpeedAndIp => {
|
||||
@@ -68,6 +70,7 @@ pub(crate) fn diff_circuits<'a>(
|
||||
|| !removed_circuits.is_empty()
|
||||
|| !speed_changed.is_empty()
|
||||
|| !ip_changed.is_empty()
|
||||
|| !migrated.is_empty()
|
||||
|| !structural_changed.is_empty()
|
||||
{
|
||||
CircuitDiffResult::Categorized(CircuitDiffCategories {
|
||||
@@ -75,6 +78,7 @@ pub(crate) fn diff_circuits<'a>(
|
||||
removed_circuits,
|
||||
speed_changed,
|
||||
ip_changed,
|
||||
migrated,
|
||||
structural_changed,
|
||||
})
|
||||
} else {
|
||||
@@ -84,7 +88,7 @@ pub(crate) fn diff_circuits<'a>(
|
||||
|
||||
enum CircuitChange {
|
||||
None,
|
||||
Structural,
|
||||
Migration,
|
||||
Speed,
|
||||
Ip,
|
||||
SpeedAndIp,
|
||||
@@ -141,7 +145,7 @@ fn classify_circuit_change(a: &BakeryCommands, b: &BakeryCommands) -> CircuitCha
|
||||
|| up_class_major != other_up_class_major;
|
||||
|
||||
if structural {
|
||||
return CircuitChange::Structural;
|
||||
return CircuitChange::Migration;
|
||||
}
|
||||
|
||||
let speed = download_bandwidth_min != other_download_bandwidth_min
|
||||
@@ -159,3 +163,133 @@ fn classify_circuit_change(a: &BakeryCommands, b: &BakeryCommands) -> CircuitCha
|
||||
(true, true) => CircuitChange::SpeedAndIp,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CircuitDiffResult, diff_circuits};
|
||||
use crate::BakeryCommands;
|
||||
use lqos_bus::TcHandle;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_circuit(
|
||||
circuit_hash: i64,
|
||||
parent_class_id: u32,
|
||||
up_parent_class_id: u32,
|
||||
class_minor: u16,
|
||||
class_major: u16,
|
||||
up_class_major: u16,
|
||||
dl_min: f32,
|
||||
ul_min: f32,
|
||||
dl_max: f32,
|
||||
ul_max: f32,
|
||||
) -> Arc<BakeryCommands> {
|
||||
Arc::new(BakeryCommands::AddCircuit {
|
||||
circuit_hash,
|
||||
circuit_name: None,
|
||||
site_name: None,
|
||||
parent_class_id: TcHandle::from_u32(parent_class_id),
|
||||
up_parent_class_id: TcHandle::from_u32(up_parent_class_id),
|
||||
class_minor,
|
||||
download_bandwidth_min: dl_min,
|
||||
upload_bandwidth_min: ul_min,
|
||||
download_bandwidth_max: dl_max,
|
||||
upload_bandwidth_max: ul_max,
|
||||
class_major,
|
||||
up_class_major,
|
||||
down_qdisc_handle: Some(0x9000),
|
||||
up_qdisc_handle: Some(0x9001),
|
||||
ip_addresses: "192.0.2.1/32".to_string(),
|
||||
sqm_override: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_move_is_categorized_as_migration() {
|
||||
let old = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0);
|
||||
let new = add_circuit(1, 0x10034, 0x20034, 0x35, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0);
|
||||
let old_circuits = HashMap::from([(1, Arc::clone(&old))]);
|
||||
let batch = vec![new];
|
||||
|
||||
let CircuitDiffResult::Categorized(categories) = diff_circuits(&batch, &old_circuits)
|
||||
else {
|
||||
panic!("expected categorized diff");
|
||||
};
|
||||
|
||||
assert_eq!(categories.migrated.len(), 1);
|
||||
assert!(categories.structural_changed.is_empty());
|
||||
assert!(categories.speed_changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_stays_speed_only() {
|
||||
let old = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0);
|
||||
let new = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 2.0, 1.5, 20.0, 15.0);
|
||||
let old_circuits = HashMap::from([(1, Arc::clone(&old))]);
|
||||
let batch = vec![new];
|
||||
|
||||
let CircuitDiffResult::Categorized(categories) = diff_circuits(&batch, &old_circuits)
|
||||
else {
|
||||
panic!("expected categorized diff");
|
||||
};
|
||||
|
||||
assert_eq!(categories.speed_changed.len(), 1);
|
||||
assert!(categories.migrated.is_empty());
|
||||
assert!(categories.structural_changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_change_stays_ip_only() {
|
||||
let old = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0);
|
||||
let BakeryCommands::AddCircuit {
|
||||
circuit_hash,
|
||||
parent_class_id,
|
||||
up_parent_class_id,
|
||||
class_minor,
|
||||
download_bandwidth_min,
|
||||
upload_bandwidth_min,
|
||||
download_bandwidth_max,
|
||||
upload_bandwidth_max,
|
||||
class_major,
|
||||
up_class_major,
|
||||
down_qdisc_handle,
|
||||
up_qdisc_handle,
|
||||
sqm_override,
|
||||
..
|
||||
} = old.as_ref()
|
||||
else {
|
||||
panic!("expected add circuit");
|
||||
};
|
||||
let new = Arc::new(BakeryCommands::AddCircuit {
|
||||
circuit_hash: *circuit_hash,
|
||||
circuit_name: None,
|
||||
site_name: None,
|
||||
parent_class_id: *parent_class_id,
|
||||
up_parent_class_id: *up_parent_class_id,
|
||||
class_minor: *class_minor,
|
||||
download_bandwidth_min: *download_bandwidth_min,
|
||||
upload_bandwidth_min: *upload_bandwidth_min,
|
||||
download_bandwidth_max: *download_bandwidth_max,
|
||||
upload_bandwidth_max: *upload_bandwidth_max,
|
||||
class_major: *class_major,
|
||||
up_class_major: *up_class_major,
|
||||
down_qdisc_handle: *down_qdisc_handle,
|
||||
up_qdisc_handle: *up_qdisc_handle,
|
||||
ip_addresses: "198.51.100.10/32".to_string(),
|
||||
sqm_override: sqm_override.clone(),
|
||||
});
|
||||
let old_circuits = HashMap::from([(1, Arc::clone(&old))]);
|
||||
let batch = vec![new];
|
||||
|
||||
let CircuitDiffResult::Categorized(categories) = diff_circuits(&batch, &old_circuits)
|
||||
else {
|
||||
panic!("expected categorized diff");
|
||||
};
|
||||
|
||||
assert_eq!(categories.ip_changed.len(), 1);
|
||||
assert!(categories.speed_changed.is_empty());
|
||||
assert!(categories.migrated.is_empty());
|
||||
assert!(categories.structural_changed.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const SITE_DELTA_PREVIEW_THRESHOLD: usize = 8;
|
||||
|
||||
pub(crate) enum SiteDiffResult {
|
||||
RebuildRequired,
|
||||
RebuildRequired { summary: String },
|
||||
SpeedChanges { changes: Vec<BakeryCommands> },
|
||||
NoChange,
|
||||
}
|
||||
@@ -29,6 +31,7 @@ pub(crate) fn diff_sites(
|
||||
// Therefore, we must rebuild the entire site configuration.
|
||||
let old_keys: Vec<i64> = old_sites.keys().cloned().collect();
|
||||
let new_keys: Vec<i64> = new_sites.keys().copied().collect();
|
||||
let (added_site_hashes, removed_site_hashes) = site_set_delta(old_sites, &new_sites);
|
||||
warn!(
|
||||
"Site count mismatch: old {} vs new {}",
|
||||
old_sites.len(),
|
||||
@@ -36,7 +39,18 @@ pub(crate) fn diff_sites(
|
||||
);
|
||||
debug!("Old site hashes: {:?}", old_keys);
|
||||
debug!("New site hashes: {:?}", new_keys);
|
||||
return SiteDiffResult::RebuildRequired;
|
||||
debug!("Added site hashes: {:?}", added_site_hashes);
|
||||
debug!("Removed site hashes: {:?}", removed_site_hashes);
|
||||
return SiteDiffResult::RebuildRequired {
|
||||
summary: format_site_delta_summary(
|
||||
"site_count_mismatch",
|
||||
None,
|
||||
old_sites.len(),
|
||||
new_sites.len(),
|
||||
&added_site_hashes,
|
||||
&removed_site_hashes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Compare each site in the old and new maps for structural differences.
|
||||
@@ -83,7 +97,12 @@ pub(crate) fn diff_sites(
|
||||
"Site hash {} change detail: parent={}→{}, up_parent={}→{}, minor=0x{:x}→0x{:x}",
|
||||
site_hash, opar, npar, oup, nup, omin, nmin
|
||||
);
|
||||
return SiteDiffResult::RebuildRequired;
|
||||
return SiteDiffResult::RebuildRequired {
|
||||
summary: format!(
|
||||
"Bakery full reload triggered by site diff: site_hash={} parent={}→{} up_parent={}→{} minor=0x{:x}→0x{:x}",
|
||||
site_hash, opar, npar, oup, nup, omin, nmin
|
||||
),
|
||||
};
|
||||
}
|
||||
// If the speeds have changed, we need to store the change.
|
||||
if let Some(speed_change) = site_speeds_changed(old_cmd.as_ref(), new_cmd.as_ref()) {
|
||||
@@ -93,7 +112,19 @@ pub(crate) fn diff_sites(
|
||||
} else {
|
||||
// If a site is missing in the new batch, we need to rebuild.
|
||||
debug!("Site hash {} is missing in the new batch", site_hash);
|
||||
return SiteDiffResult::RebuildRequired;
|
||||
let (added_site_hashes, removed_site_hashes) = site_set_delta(old_sites, &new_sites);
|
||||
debug!("Added site hashes: {:?}", added_site_hashes);
|
||||
debug!("Removed site hashes: {:?}", removed_site_hashes);
|
||||
return SiteDiffResult::RebuildRequired {
|
||||
summary: format_site_delta_summary(
|
||||
"site_set_changed",
|
||||
Some(*site_hash),
|
||||
old_sites.len(),
|
||||
new_sites.len(),
|
||||
&added_site_hashes,
|
||||
&removed_site_hashes,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +138,67 @@ pub(crate) fn diff_sites(
|
||||
SiteDiffResult::NoChange
|
||||
}
|
||||
|
||||
fn site_set_delta(
|
||||
old_sites: &HashMap<i64, Arc<BakeryCommands>>,
|
||||
new_sites: &HashMap<i64, &Arc<BakeryCommands>>,
|
||||
) -> (Vec<i64>, Vec<i64>) {
|
||||
let mut added_site_hashes: Vec<i64> = new_sites
|
||||
.keys()
|
||||
.filter(|hash| !old_sites.contains_key(hash))
|
||||
.copied()
|
||||
.collect();
|
||||
added_site_hashes.sort_unstable();
|
||||
|
||||
let mut removed_site_hashes: Vec<i64> = old_sites
|
||||
.keys()
|
||||
.filter(|hash| !new_sites.contains_key(hash))
|
||||
.copied()
|
||||
.collect();
|
||||
removed_site_hashes.sort_unstable();
|
||||
|
||||
(added_site_hashes, removed_site_hashes)
|
||||
}
|
||||
|
||||
fn format_site_hash_preview(label: &str, hashes: &[i64], total_changed: usize) -> Option<String> {
|
||||
if hashes.is_empty()
|
||||
|| hashes.len() > SITE_DELTA_PREVIEW_THRESHOLD
|
||||
|| total_changed > SITE_DELTA_PREVIEW_THRESHOLD
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!(" {label}={hashes:?}"))
|
||||
}
|
||||
|
||||
fn format_site_delta_summary(
|
||||
reason: &str,
|
||||
missing_old_site_hash: Option<i64>,
|
||||
old_count: usize,
|
||||
new_count: usize,
|
||||
added_site_hashes: &[i64],
|
||||
removed_site_hashes: &[i64],
|
||||
) -> String {
|
||||
let mut summary = format!(
|
||||
"Bakery full reload triggered by site diff: {reason} old_count={old_count} new_count={new_count} added_count={} removed_count={}",
|
||||
added_site_hashes.len(),
|
||||
removed_site_hashes.len()
|
||||
);
|
||||
let total_changed = added_site_hashes.len() + removed_site_hashes.len();
|
||||
if let Some(site_hash) = missing_old_site_hash {
|
||||
summary.push_str(&format!(" missing_old_site_hash={site_hash}"));
|
||||
}
|
||||
if let Some(preview) =
|
||||
format_site_hash_preview("added_preview", added_site_hashes, total_changed)
|
||||
{
|
||||
summary.push_str(&preview);
|
||||
}
|
||||
if let Some(preview) =
|
||||
format_site_hash_preview("removed_preview", removed_site_hashes, total_changed)
|
||||
{
|
||||
summary.push_str(&preview);
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn is_structurally_different(a: &BakeryCommands, b: &BakeryCommands) -> bool {
|
||||
let BakeryCommands::AddSite {
|
||||
site_hash,
|
||||
@@ -211,3 +303,104 @@ fn site_speeds_changed(a: &BakeryCommands, b: &BakeryCommands) -> Option<BakeryC
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SiteDiffResult, diff_sites};
|
||||
use crate::BakeryCommands;
|
||||
use lqos_bus::TcHandle;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_site(
|
||||
site_hash: i64,
|
||||
parent_class_id: u32,
|
||||
up_parent_class_id: u32,
|
||||
class_minor: u16,
|
||||
dl_min: f32,
|
||||
ul_min: f32,
|
||||
dl_max: f32,
|
||||
ul_max: f32,
|
||||
) -> Arc<BakeryCommands> {
|
||||
Arc::new(BakeryCommands::AddSite {
|
||||
site_hash,
|
||||
parent_class_id: TcHandle::from_u32(parent_class_id),
|
||||
up_parent_class_id: TcHandle::from_u32(up_parent_class_id),
|
||||
class_minor,
|
||||
download_bandwidth_min: dl_min,
|
||||
upload_bandwidth_min: ul_min,
|
||||
download_bandwidth_max: dl_max,
|
||||
upload_bandwidth_max: ul_max,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_count_mismatch_reports_added_and_removed_hashes() {
|
||||
let old_site = add_site(10, 0x10010, 0x20010, 0x11, 1.0, 1.0, 10.0, 10.0);
|
||||
let new_site = add_site(20, 0x10020, 0x20020, 0x21, 1.0, 1.0, 10.0, 10.0);
|
||||
let old_sites = HashMap::from([(10, old_site)]);
|
||||
let batch = vec![
|
||||
new_site,
|
||||
add_site(30, 0x10030, 0x20030, 0x31, 1.0, 1.0, 10.0, 10.0),
|
||||
];
|
||||
|
||||
let SiteDiffResult::RebuildRequired { summary } = diff_sites(&batch, &old_sites) else {
|
||||
panic!("expected rebuild-required site diff");
|
||||
};
|
||||
|
||||
assert!(summary.contains("site_count_mismatch"));
|
||||
assert!(summary.contains("added_count=2"));
|
||||
assert!(summary.contains("removed_count=1"));
|
||||
assert!(summary.contains("added_preview=[20, 30]"));
|
||||
assert!(summary.contains("removed_preview=[10]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_site_count_mismatch_stays_counts_only() {
|
||||
let old_sites = HashMap::from([(
|
||||
10,
|
||||
add_site(10, 0x10010, 0x20010, 0x11, 1.0, 1.0, 10.0, 10.0),
|
||||
)]);
|
||||
let mut batch = Vec::new();
|
||||
for index in 20..30 {
|
||||
batch.push(add_site(
|
||||
index,
|
||||
0x10000 | index as u32,
|
||||
0x20000 | index as u32,
|
||||
index as u16,
|
||||
1.0,
|
||||
1.0,
|
||||
10.0,
|
||||
10.0,
|
||||
));
|
||||
}
|
||||
|
||||
let SiteDiffResult::RebuildRequired { summary } = diff_sites(&batch, &old_sites) else {
|
||||
panic!("expected rebuild-required site diff");
|
||||
};
|
||||
|
||||
assert!(summary.contains("site_count_mismatch"));
|
||||
assert!(summary.contains("added_count=10"));
|
||||
assert!(summary.contains("removed_count=1"));
|
||||
assert!(!summary.contains("added_preview="));
|
||||
assert!(!summary.contains("removed_preview="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_site_change_reports_parent_transition() {
|
||||
let old_site = add_site(10, 0x10010, 0x20010, 0x11, 1.0, 1.0, 10.0, 10.0);
|
||||
let new_site = add_site(10, 0x10020, 0x20020, 0x21, 1.0, 1.0, 10.0, 10.0);
|
||||
let old_sites = HashMap::from([(10, old_site)]);
|
||||
let batch = vec![new_site];
|
||||
|
||||
let SiteDiffResult::RebuildRequired { summary } = diff_sites(&batch, &old_sites) else {
|
||||
panic!("expected rebuild-required site diff");
|
||||
};
|
||||
|
||||
assert!(summary.contains("site_hash=10"));
|
||||
assert!(summary.contains("parent=0x1:0x10→0x1:0x20"));
|
||||
assert!(summary.contains("up_parent=0x2:0x10→0x2:0x20"));
|
||||
assert!(summary.contains("minor=0x11→0x21"));
|
||||
}
|
||||
}
|
||||
+11670
-378
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,494 @@
|
||||
use lqos_config::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Root MQ handle major reserved by LibreQoS.
|
||||
pub(crate) const MQ_ROOT_HANDLE_MAJOR: u16 = 0x7FFF;
|
||||
/// Prefer allocating dynamic circuit qdiscs from the upper handle space first.
|
||||
const PREFERRED_CIRCUIT_START: u16 = 0x9000;
|
||||
/// Highest qdisc handle major we will allocate dynamically.
|
||||
const MAX_DYNAMIC_HANDLE_MAJOR: u16 = 0xFFFE;
|
||||
/// Base range used for deterministic per-queue infrastructure qdisc handles.
|
||||
const INFRA_HANDLE_BASE: u16 = 0x2000;
|
||||
/// JSON version for persisted Bakery qdisc-handle state.
|
||||
const QDISC_HANDLE_FILE_VERSION: u32 = 1;
|
||||
const QDISC_HANDLE_FILE: &str = "bakery_qdisc_handles.json";
|
||||
|
||||
/// Per-device MQ queue layout used to reserve non-circuit qdisc handles.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct MqDeviceLayout {
|
||||
devices: BTreeMap<String, BTreeSet<u16>>,
|
||||
}
|
||||
|
||||
impl MqDeviceLayout {
|
||||
/// Builds the reserved queue-major layout from the current MQ setup command.
|
||||
pub(crate) fn from_setup(
|
||||
config: &Config,
|
||||
queues_available: usize,
|
||||
stick_offset: usize,
|
||||
) -> Self {
|
||||
let mut devices = BTreeMap::<String, BTreeSet<u16>>::new();
|
||||
|
||||
let isp = config.isp_interface().to_string();
|
||||
let isp_entry = devices.entry(isp).or_default();
|
||||
for queue in 0..queues_available {
|
||||
let major = (queue + 1) as u16;
|
||||
isp_entry.insert(major);
|
||||
}
|
||||
|
||||
let internet = config.internet_interface().to_string();
|
||||
let internet_entry = devices.entry(internet).or_default();
|
||||
for queue in 0..queues_available {
|
||||
let major = (queue + stick_offset + 1) as u16;
|
||||
internet_entry.insert(major);
|
||||
}
|
||||
|
||||
Self { devices }
|
||||
}
|
||||
|
||||
/// Returns the qdisc-handle majors reserved on a single interface.
|
||||
pub(crate) fn reserved_handles(&self, interface: &str) -> HashSet<u16> {
|
||||
let mut reserved = HashSet::new();
|
||||
reserved.insert(MQ_ROOT_HANDLE_MAJOR);
|
||||
reserved.insert(0xFFFF);
|
||||
|
||||
if let Some(majors) = self.devices.get(interface) {
|
||||
for major in majors {
|
||||
reserved.insert(*major);
|
||||
reserved.insert(infra_qdisc_handle(*major, InfraQdiscSlot::Primary));
|
||||
reserved.insert(infra_qdisc_handle(*major, InfraQdiscSlot::Default));
|
||||
}
|
||||
}
|
||||
|
||||
reserved
|
||||
}
|
||||
}
|
||||
|
||||
/// Slot identifier for fixed per-queue infrastructure qdiscs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InfraQdiscSlot {
|
||||
Primary,
|
||||
Default,
|
||||
}
|
||||
|
||||
/// Computes the deterministic explicit qdisc handle for a fixed infrastructure leaf.
|
||||
pub(crate) fn infra_qdisc_handle(queue_major: u16, slot: InfraQdiscSlot) -> u16 {
|
||||
let base = INFRA_HANDLE_BASE.saturating_add(queue_major.saturating_mul(2));
|
||||
match slot {
|
||||
InfraQdiscSlot::Primary => base,
|
||||
InfraQdiscSlot::Default => base.saturating_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted circuit-to-qdisc handle assignments, grouped by interface.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct QdiscHandleState {
|
||||
interfaces: BTreeMap<String, InterfaceHandleState>,
|
||||
retired_handles: BTreeMap<String, BTreeSet<u16>>,
|
||||
}
|
||||
|
||||
impl QdiscHandleState {
|
||||
/// Loads qdisc-handle state from the LibreQoS runtime directory.
|
||||
pub(crate) fn load(config: &Config) -> Self {
|
||||
let path = state_path(config);
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return Self::default();
|
||||
};
|
||||
let Ok(file) = serde_json::from_str::<PersistedQdiscHandleState>(&raw) else {
|
||||
warn!(
|
||||
"Bakery: ignoring unreadable qdisc-handle state at {:?}",
|
||||
path
|
||||
);
|
||||
return Self::default();
|
||||
};
|
||||
if file.version != QDISC_HANDLE_FILE_VERSION {
|
||||
debug!(
|
||||
"Bakery: ignoring qdisc-handle state version {} at {:?}",
|
||||
file.version, path
|
||||
);
|
||||
return Self::default();
|
||||
}
|
||||
|
||||
let interfaces = file
|
||||
.interfaces
|
||||
.into_iter()
|
||||
.map(|(iface, entry)| {
|
||||
let circuits = entry
|
||||
.circuits
|
||||
.into_iter()
|
||||
.filter_map(|(hash, handle)| hash.parse::<i64>().ok().map(|h| (h, handle)))
|
||||
.collect();
|
||||
(iface, InterfaceHandleState::from_circuits(circuits))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
interfaces,
|
||||
retired_handles: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears transient retired-handle reservations from the previous apply cycle.
|
||||
pub(crate) fn clear_retired_handles(&mut self) {
|
||||
for (interface, retired) in self.retired_handles.iter() {
|
||||
let Some(state) = self.interfaces.get_mut(interface) else {
|
||||
continue;
|
||||
};
|
||||
for handle in retired {
|
||||
state.note_reusable_handle(*handle);
|
||||
}
|
||||
}
|
||||
self.retired_handles.clear();
|
||||
}
|
||||
|
||||
/// Saves qdisc-handle state to the LibreQoS runtime directory.
|
||||
pub(crate) fn save(&self, config: &Config) {
|
||||
let path = state_path(config);
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
warn!(
|
||||
"Bakery: unable to create qdisc-handle state directory {:?}: {}",
|
||||
parent, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let persisted = PersistedQdiscHandleState {
|
||||
version: QDISC_HANDLE_FILE_VERSION,
|
||||
interfaces: self
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|(iface, circuits)| {
|
||||
(
|
||||
iface.clone(),
|
||||
PersistedInterfaceHandleState {
|
||||
circuits: circuits
|
||||
.circuits
|
||||
.iter()
|
||||
.map(|(hash, handle)| (hash.to_string(), *handle))
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let Ok(serialized) = serde_json::to_string_pretty(&persisted) else {
|
||||
warn!("Bakery: unable to serialize qdisc-handle state");
|
||||
return;
|
||||
};
|
||||
|
||||
let temp_path = path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&temp_path, serialized.as_bytes()) {
|
||||
warn!(
|
||||
"Bakery: unable to write temporary qdisc-handle state {:?}: {}",
|
||||
temp_path, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::rename(&temp_path, &path) {
|
||||
warn!(
|
||||
"Bakery: unable to atomically replace qdisc-handle state {:?}: {}",
|
||||
path, e
|
||||
);
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a stable explicit qdisc handle for the circuit on the given interface.
|
||||
pub(crate) fn assign_circuit_handle(
|
||||
&mut self,
|
||||
interface: &str,
|
||||
circuit_hash: i64,
|
||||
reserved: &HashSet<u16>,
|
||||
) -> Option<u16> {
|
||||
if let Some(existing) = self
|
||||
.interfaces
|
||||
.get(interface)
|
||||
.and_then(|state| state.circuits.get(&circuit_hash))
|
||||
.copied()
|
||||
&& !reserved.contains(&existing)
|
||||
&& self
|
||||
.interfaces
|
||||
.get(interface)
|
||||
.and_then(|state| state.owner_of(existing))
|
||||
== Some(circuit_hash)
|
||||
{
|
||||
return Some(existing);
|
||||
}
|
||||
|
||||
let retired = self.retired_handles.get(interface);
|
||||
let state = self.interfaces.entry(interface.to_string()).or_default();
|
||||
let next = state.next_free_handle(reserved, retired)?;
|
||||
state.insert(circuit_hash, next);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// Forces a circuit to rotate to a different explicit qdisc handle on an interface.
|
||||
pub(crate) fn rotate_circuit_handle(
|
||||
&mut self,
|
||||
interface: &str,
|
||||
circuit_hash: i64,
|
||||
reserved: &HashSet<u16>,
|
||||
) -> Option<u16> {
|
||||
let retired_handle = self
|
||||
.interfaces
|
||||
.get(interface)
|
||||
.and_then(|state| state.circuits.get(&circuit_hash))
|
||||
.copied();
|
||||
|
||||
let retired = self.retired_handles.get(interface);
|
||||
let state = self.interfaces.entry(interface.to_string()).or_default();
|
||||
let next = state.next_free_handle(reserved, retired)?;
|
||||
if let Some(handle) = retired_handle {
|
||||
self.retired_handles
|
||||
.entry(interface.to_string())
|
||||
.or_default()
|
||||
.insert(handle);
|
||||
}
|
||||
state.insert(circuit_hash, next);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// Releases a circuit handle on an interface so it can be reused deterministically.
|
||||
pub(crate) fn release_circuit(&mut self, interface: &str, circuit_hash: i64) -> bool {
|
||||
self.interfaces
|
||||
.get_mut(interface)
|
||||
.and_then(|state| state.remove(circuit_hash))
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct InterfaceHandleState {
|
||||
circuits: BTreeMap<i64, u16>,
|
||||
owners: HashMap<u16, i64>,
|
||||
next_candidate: u16,
|
||||
}
|
||||
|
||||
impl Default for InterfaceHandleState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
circuits: BTreeMap::new(),
|
||||
owners: HashMap::new(),
|
||||
next_candidate: PREFERRED_CIRCUIT_START,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceHandleState {
|
||||
fn from_circuits(circuits: BTreeMap<i64, u16>) -> Self {
|
||||
let mut state = Self {
|
||||
circuits,
|
||||
owners: HashMap::new(),
|
||||
next_candidate: PREFERRED_CIRCUIT_START,
|
||||
};
|
||||
for (circuit_hash, handle) in state.circuits.iter() {
|
||||
state.owners.insert(*handle, *circuit_hash);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn owner_of(&self, handle: u16) -> Option<i64> {
|
||||
self.owners.get(&handle).copied()
|
||||
}
|
||||
|
||||
fn insert(&mut self, circuit_hash: i64, handle: u16) {
|
||||
if let Some(previous) = self.circuits.insert(circuit_hash, handle) {
|
||||
self.owners.remove(&previous);
|
||||
self.note_reusable_handle(previous);
|
||||
}
|
||||
self.owners.insert(handle, circuit_hash);
|
||||
if self.next_candidate == handle {
|
||||
self.next_candidate = advance_handle_candidate(handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, circuit_hash: i64) -> Option<u16> {
|
||||
let removed = self.circuits.remove(&circuit_hash)?;
|
||||
self.owners.remove(&removed);
|
||||
self.note_reusable_handle(removed);
|
||||
Some(removed)
|
||||
}
|
||||
|
||||
fn note_reusable_handle(&mut self, handle: u16) {
|
||||
if handle >= PREFERRED_CIRCUIT_START || self.next_candidate < PREFERRED_CIRCUIT_START {
|
||||
self.next_candidate = self.next_candidate.min(handle);
|
||||
} else if self.circuits.is_empty() {
|
||||
self.next_candidate = PREFERRED_CIRCUIT_START;
|
||||
}
|
||||
}
|
||||
|
||||
fn next_free_handle(
|
||||
&mut self,
|
||||
reserved: &HashSet<u16>,
|
||||
retired: Option<&BTreeSet<u16>>,
|
||||
) -> Option<u16> {
|
||||
if let Some(handle) = self.scan_available(
|
||||
self.next_candidate,
|
||||
MAX_DYNAMIC_HANDLE_MAJOR,
|
||||
reserved,
|
||||
retired,
|
||||
) {
|
||||
self.next_candidate = advance_handle_candidate(handle);
|
||||
return Some(handle);
|
||||
}
|
||||
|
||||
if self.next_candidate > PREFERRED_CIRCUIT_START
|
||||
&& let Some(handle) = self.scan_available(
|
||||
PREFERRED_CIRCUIT_START,
|
||||
self.next_candidate - 1,
|
||||
reserved,
|
||||
retired,
|
||||
)
|
||||
{
|
||||
self.next_candidate = advance_handle_candidate(handle);
|
||||
return Some(handle);
|
||||
}
|
||||
|
||||
let low_end = PREFERRED_CIRCUIT_START.saturating_sub(1);
|
||||
let low_start = if self.next_candidate < PREFERRED_CIRCUIT_START {
|
||||
self.next_candidate.max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
if let Some(handle) = self.scan_available(low_start, low_end, reserved, retired) {
|
||||
self.next_candidate = advance_handle_candidate(handle);
|
||||
return Some(handle);
|
||||
}
|
||||
|
||||
if low_start > 1
|
||||
&& let Some(handle) = self.scan_available(1, low_start - 1, reserved, retired)
|
||||
{
|
||||
self.next_candidate = advance_handle_candidate(handle);
|
||||
return Some(handle);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn scan_available(
|
||||
&self,
|
||||
start: u16,
|
||||
end: u16,
|
||||
reserved: &HashSet<u16>,
|
||||
retired: Option<&BTreeSet<u16>>,
|
||||
) -> Option<u16> {
|
||||
if start > end {
|
||||
return None;
|
||||
}
|
||||
|
||||
for handle in start..=end {
|
||||
if reserved.contains(&handle) {
|
||||
continue;
|
||||
}
|
||||
if self.owners.contains_key(&handle) {
|
||||
continue;
|
||||
}
|
||||
if retired.is_some_and(|set| set.contains(&handle)) {
|
||||
continue;
|
||||
}
|
||||
return Some(handle);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_handle_candidate(handle: u16) -> u16 {
|
||||
if handle >= MAX_DYNAMIC_HANDLE_MAJOR {
|
||||
1
|
||||
} else {
|
||||
handle + 1
|
||||
}
|
||||
}
|
||||
|
||||
fn state_path(config: &Config) -> PathBuf {
|
||||
Path::new(&config.lqos_directory).join(QDISC_HANDLE_FILE)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PersistedQdiscHandleState {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
interfaces: BTreeMap<String, PersistedInterfaceHandleState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct PersistedInterfaceHandleState {
|
||||
#[serde(default)]
|
||||
circuits: BTreeMap<String, u16>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_primary_default_handles_are_unique() {
|
||||
let primary = infra_qdisc_handle(1, InfraQdiscSlot::Primary);
|
||||
let default = infra_qdisc_handle(1, InfraQdiscSlot::Default);
|
||||
assert_ne!(primary, default);
|
||||
assert_eq!(primary, 0x2002);
|
||||
assert_eq!(default, 0x2003);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocator_prefers_high_range_and_reuses_existing() {
|
||||
let mut state = QdiscHandleState::default();
|
||||
let reserved = HashSet::from([0x7FFF, 0xFFFF]);
|
||||
let a = state
|
||||
.assign_circuit_handle("eth0", 1, &reserved)
|
||||
.expect("first handle");
|
||||
let b = state
|
||||
.assign_circuit_handle("eth0", 2, &reserved)
|
||||
.expect("second handle");
|
||||
assert_eq!(a, PREFERRED_CIRCUIT_START);
|
||||
assert_eq!(b, PREFERRED_CIRCUIT_START + 1);
|
||||
let a_again = state
|
||||
.assign_circuit_handle("eth0", 1, &reserved)
|
||||
.expect("reused handle");
|
||||
assert_eq!(a, a_again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocator_can_rotate_to_a_new_handle() {
|
||||
let mut state = QdiscHandleState::default();
|
||||
let reserved = HashSet::from([0x7FFF, 0xFFFF]);
|
||||
let first = state
|
||||
.assign_circuit_handle("eth0", 1, &reserved)
|
||||
.expect("first handle");
|
||||
let rotated = state
|
||||
.rotate_circuit_handle("eth0", 1, &reserved)
|
||||
.expect("rotated handle");
|
||||
assert_ne!(first, rotated);
|
||||
assert_eq!(rotated, first + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_handles_are_not_reused_until_cleared() {
|
||||
let mut state = QdiscHandleState::default();
|
||||
let reserved = HashSet::from([0x7FFF, 0xFFFF]);
|
||||
let first = state
|
||||
.assign_circuit_handle("eth0", 1, &reserved)
|
||||
.expect("first handle");
|
||||
let rotated = state
|
||||
.rotate_circuit_handle("eth0", 1, &reserved)
|
||||
.expect("rotated handle");
|
||||
let second_circuit = state
|
||||
.assign_circuit_handle("eth0", 2, &reserved)
|
||||
.expect("second circuit handle");
|
||||
assert_ne!(second_circuit, first);
|
||||
assert_eq!(second_circuit, rotated + 1);
|
||||
|
||||
state.clear_retired_handles();
|
||||
let third_circuit = state
|
||||
.assign_circuit_handle("eth0", 3, &reserved)
|
||||
.expect("third circuit handle");
|
||||
assert_eq!(third_circuit, first);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SqmKind {
|
||||
Cake,
|
||||
FqCodel,
|
||||
}
|
||||
|
||||
pub(crate) fn sqm_as_vec(config: &Arc<lqos_config::Config>) -> Vec<String> {
|
||||
config
|
||||
.queues
|
||||
@@ -192,3 +198,17 @@ pub(crate) fn sqm_tokens_for(
|
||||
Some(_) => sqm_rate_fixup(rate, config), // defensive fallback
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn effective_sqm_kind(
|
||||
rate: f32,
|
||||
config: &Arc<lqos_config::Config>,
|
||||
override_opt: &Option<String>,
|
||||
) -> SqmKind {
|
||||
match sqm_tokens_for(rate, config, override_opt)
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
{
|
||||
Some("fq_codel") => SqmKind::FqCodel,
|
||||
_ => SqmKind::Cake,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
use lqos_bus::TcHandle;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use tracing::{error, info};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Get the current Unix timestamp in seconds
|
||||
pub(crate) fn current_timestamp() -> u64 {
|
||||
@@ -13,79 +17,709 @@ pub(crate) fn current_timestamp() -> u64 {
|
||||
}
|
||||
|
||||
static FILE_LOCK: Mutex<()> = Mutex::new(());
|
||||
static LIVE_TC_SNAPSHOT_CACHE: LazyLock<Mutex<LiveTcSnapshotCache>> =
|
||||
LazyLock::new(|| Mutex::new(LiveTcSnapshotCache::new()));
|
||||
static TC_IO_CADENCE_STATE: LazyLock<Mutex<TcIoCadenceState>> =
|
||||
LazyLock::new(|| Mutex::new(TcIoCadenceState::new()));
|
||||
const LIVE_TC_SNAPSHOT_MAX_AGE_MS: u64 = 250;
|
||||
const TC_IO_INTERVAL_WINDOW: usize = 128;
|
||||
|
||||
pub(crate) fn execute_in_memory(command_buffer: &Vec<Vec<String>>, purpose: &str) {
|
||||
let lock = FILE_LOCK.lock();
|
||||
#[derive(Clone)]
|
||||
struct TimedClassSnapshot {
|
||||
captured_at: Instant,
|
||||
snapshot: HashMap<TcHandle, LiveTcClassEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TimedQdiscSnapshot {
|
||||
captured_at: Instant,
|
||||
entries: Vec<LiveTcQdiscEntry>,
|
||||
}
|
||||
|
||||
struct LiveTcSnapshotCache {
|
||||
class_snapshots: HashMap<String, TimedClassSnapshot>,
|
||||
qdisc_snapshots: HashMap<String, TimedQdiscSnapshot>,
|
||||
}
|
||||
|
||||
impl LiveTcSnapshotCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
class_snapshots: HashMap::new(),
|
||||
qdisc_snapshots: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate(&mut self) {
|
||||
self.class_snapshots.clear();
|
||||
self.qdisc_snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
struct TcIoCadenceState {
|
||||
last_event_at: Option<Instant>,
|
||||
last_event_unix: Option<u64>,
|
||||
intervals_ms: VecDeque<u64>,
|
||||
interval_sum_ms: u128,
|
||||
}
|
||||
|
||||
impl TcIoCadenceState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
last_event_at: None,
|
||||
last_event_unix: None,
|
||||
intervals_ms: VecDeque::new(),
|
||||
interval_sum_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_event(&mut self) {
|
||||
let now = Instant::now();
|
||||
let now_unix = current_timestamp();
|
||||
if let Some(last) = self.last_event_at {
|
||||
let interval_ms = now.duration_since(last).as_millis() as u64;
|
||||
self.intervals_ms.push_back(interval_ms);
|
||||
self.interval_sum_ms = self.interval_sum_ms.saturating_add(u128::from(interval_ms));
|
||||
while self.intervals_ms.len() > TC_IO_INTERVAL_WINDOW {
|
||||
if let Some(removed) = self.intervals_ms.pop_front() {
|
||||
self.interval_sum_ms = self.interval_sum_ms.saturating_sub(u128::from(removed));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_event_at = Some(now);
|
||||
self.last_event_unix = Some(now_unix);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> TcIoCadenceSnapshot {
|
||||
let sample_count = self.intervals_ms.len();
|
||||
let avg_interval_ms = if sample_count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((self.interval_sum_ms / sample_count as u128) as u64)
|
||||
};
|
||||
TcIoCadenceSnapshot {
|
||||
avg_interval_ms,
|
||||
last_event_unix: self.last_event_unix,
|
||||
sample_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TcIoCadenceSnapshot {
|
||||
pub(crate) avg_interval_ms: Option<u64>,
|
||||
pub(crate) last_event_unix: Option<u64>,
|
||||
pub(crate) sample_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ExecuteResult {
|
||||
pub(crate) ok: bool,
|
||||
pub(crate) duration_ms: u64,
|
||||
pub(crate) failure_summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightweight host-memory snapshot used by Bakery safety checks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MemorySnapshot {
|
||||
/// Total installed RAM in bytes.
|
||||
pub total_bytes: u64,
|
||||
/// Currently available RAM in bytes.
|
||||
pub available_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LiveTcClassEntry {
|
||||
pub(crate) class_id: TcHandle,
|
||||
pub(crate) parent: Option<TcHandle>,
|
||||
pub(crate) leaf_qdisc_major: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LiveTcQdiscEntry {
|
||||
pub(crate) kind: String,
|
||||
pub(crate) handle: Option<TcHandle>,
|
||||
pub(crate) parent: Option<TcHandle>,
|
||||
pub(crate) is_root: bool,
|
||||
}
|
||||
|
||||
fn format_numbered_lines(lines: &str, starting_line_number: usize) -> String {
|
||||
let mut numbered = String::new();
|
||||
for (i, line) in lines.lines().enumerate() {
|
||||
numbered.push_str(&format!("{:>4}: {}\n", starting_line_number + i, line));
|
||||
}
|
||||
numbered
|
||||
}
|
||||
|
||||
fn run_tc_batch(path: &Path, purpose: &str) -> Result<std::process::Output, String> {
|
||||
record_tc_io_event();
|
||||
std::process::Command::new("/sbin/tc")
|
||||
.args(["-f", "-batch", path.to_str().unwrap_or_default()])
|
||||
.output()
|
||||
.map_err(|_| format!("Failed to execute tc batch command for {purpose}."))
|
||||
}
|
||||
|
||||
fn summarize_tc_batch_failure(output: &std::process::Output) -> Option<String> {
|
||||
if output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = stderr.trim();
|
||||
|
||||
let status_summary = match output.status.code() {
|
||||
Some(code) => format!("tc batch exited with status {code}"),
|
||||
None => "tc batch terminated by signal".to_string(),
|
||||
};
|
||||
|
||||
if stderr.is_empty() {
|
||||
Some(status_summary)
|
||||
} else {
|
||||
Some(format!("{status_summary}: {stderr}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tc_batch_command_is_delete_only(line: &str) -> bool {
|
||||
let mut parts = line.split_whitespace();
|
||||
matches!(
|
||||
(parts.next(), parts.next()),
|
||||
(Some("qdisc"), Some("del")) | (Some("class"), Some("del"))
|
||||
)
|
||||
}
|
||||
|
||||
fn tc_batch_failure_is_ignorable_delete_absence(
|
||||
output: &std::process::Output,
|
||||
lines: &str,
|
||||
) -> bool {
|
||||
if output.status.success() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if lines.trim().is_empty() || !lines.lines().all(tc_batch_command_is_delete_only) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
stderr.lines().all(|line| {
|
||||
let trimmed = line.trim().to_ascii_lowercase();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with("command failed ")
|
||||
|| trimmed.starts_with("error: specified class not found")
|
||||
|| trimmed.starts_with("error: cannot find specified qdisc on specified device")
|
||||
|| trimmed.starts_with("rtnetlink answers: no such file or directory")
|
||||
})
|
||||
}
|
||||
|
||||
fn tc_success_stderr_is_harmless(stderr: &str) -> bool {
|
||||
let trimmed = stderr.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
trimmed.lines().all(|line| {
|
||||
let normalized = line.trim();
|
||||
normalized.is_empty() || normalized.to_ascii_lowercase().starts_with("warning:")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_memory_snapshot() -> Result<MemorySnapshot, String> {
|
||||
let raw = std::fs::read_to_string("/proc/meminfo")
|
||||
.map_err(|e| format!("Failed to read /proc/meminfo: {e}"))?;
|
||||
parse_memory_snapshot(&raw)
|
||||
}
|
||||
|
||||
fn parse_memory_snapshot(raw: &str) -> Result<MemorySnapshot, String> {
|
||||
let mut total_kib = None;
|
||||
let mut available_kib = None;
|
||||
|
||||
for line in raw.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let kib = value
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|n| n.parse::<u64>().ok());
|
||||
match key.trim() {
|
||||
"MemTotal" => total_kib = kib,
|
||||
"MemAvailable" => available_kib = kib,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let total_kib = total_kib.ok_or_else(|| "MemTotal missing from /proc/meminfo".to_string())?;
|
||||
let available_kib =
|
||||
available_kib.ok_or_else(|| "MemAvailable missing from /proc/meminfo".to_string())?;
|
||||
|
||||
Ok(MemorySnapshot {
|
||||
total_bytes: total_kib.saturating_mul(1024),
|
||||
available_bytes: available_kib.saturating_mul(1024),
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_guard_failure_summary(
|
||||
snapshot: MemorySnapshot,
|
||||
min_available_bytes: u64,
|
||||
purpose: &str,
|
||||
chunk_number: usize,
|
||||
total_chunks: usize,
|
||||
phase: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"Bakery memory guard stopped {purpose} {phase} chunk {}/{}: available memory {} bytes is below safety floor {} bytes (total RAM {} bytes).",
|
||||
chunk_number,
|
||||
total_chunks,
|
||||
snapshot.available_bytes,
|
||||
min_available_bytes,
|
||||
snapshot.total_bytes
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn invalidate_live_tc_snapshots() {
|
||||
let mut cache = LIVE_TC_SNAPSHOT_CACHE.lock();
|
||||
cache.invalidate();
|
||||
}
|
||||
|
||||
fn record_tc_io_event() {
|
||||
TC_IO_CADENCE_STATE.lock().record_event();
|
||||
}
|
||||
|
||||
pub(crate) fn tc_io_cadence_snapshot() -> TcIoCadenceSnapshot {
|
||||
TC_IO_CADENCE_STATE.lock().snapshot()
|
||||
}
|
||||
|
||||
fn read_live_qdisc_snapshot_raw(interface: &str) -> Result<Vec<LiveTcQdiscEntry>, String> {
|
||||
record_tc_io_event();
|
||||
let output = std::process::Command::new("/sbin/tc")
|
||||
.args(["-s", "-j", "qdisc", "show", "dev", interface])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to snapshot live qdiscs on {interface}: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Failed to snapshot live qdiscs on {interface}: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.map_err(|e| format!("Live qdisc snapshot on {interface} was not UTF-8: {e}"))?;
|
||||
|
||||
parse_live_qdisc_snapshot(&stdout)
|
||||
.map_err(|e| format!("Failed to parse live qdisc snapshot on {interface}: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_live_qdisc_snapshot(interface: &str) -> Result<Vec<LiveTcQdiscEntry>, String> {
|
||||
let _lock = FILE_LOCK.lock();
|
||||
let mut cache = LIVE_TC_SNAPSHOT_CACHE.lock();
|
||||
let now = Instant::now();
|
||||
if let Some(entry) = cache.qdisc_snapshots.get(interface)
|
||||
&& now.duration_since(entry.captured_at)
|
||||
<= Duration::from_millis(LIVE_TC_SNAPSHOT_MAX_AGE_MS)
|
||||
{
|
||||
return Ok(entry.entries.clone());
|
||||
}
|
||||
|
||||
let entries = read_live_qdisc_snapshot_raw(interface)?;
|
||||
cache.qdisc_snapshots.insert(
|
||||
interface.to_string(),
|
||||
TimedQdiscSnapshot {
|
||||
captured_at: now,
|
||||
entries: entries.clone(),
|
||||
},
|
||||
);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn read_live_qdisc_handle_majors(interface: &str) -> Result<HashSet<u16>, String> {
|
||||
let entries = read_live_qdisc_snapshot(interface)?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.handle)
|
||||
.filter_map(|handle| {
|
||||
let (major, _) = handle.get_major_minor();
|
||||
(major != 0).then_some(major)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_live_class_snapshot_raw(
|
||||
interface: &str,
|
||||
) -> Result<HashMap<TcHandle, LiveTcClassEntry>, String> {
|
||||
record_tc_io_event();
|
||||
let output = std::process::Command::new("/sbin/tc")
|
||||
.args(["class", "show", "dev", interface])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to snapshot live classes on {interface}: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Failed to snapshot live classes on {interface}: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.map_err(|e| format!("Live class snapshot on {interface} was not UTF-8: {e}"))?;
|
||||
|
||||
parse_live_class_snapshot(&stdout)
|
||||
.map_err(|e| format!("Failed to parse live class snapshot on {interface}: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_live_class_snapshot(
|
||||
interface: &str,
|
||||
) -> Result<HashMap<TcHandle, LiveTcClassEntry>, String> {
|
||||
let _lock = FILE_LOCK.lock();
|
||||
let mut cache = LIVE_TC_SNAPSHOT_CACHE.lock();
|
||||
let now = Instant::now();
|
||||
if let Some(entry) = cache.class_snapshots.get(interface)
|
||||
&& now.duration_since(entry.captured_at)
|
||||
<= Duration::from_millis(LIVE_TC_SNAPSHOT_MAX_AGE_MS)
|
||||
{
|
||||
return Ok(entry.snapshot.clone());
|
||||
}
|
||||
|
||||
let snapshot = read_live_class_snapshot_raw(interface)?;
|
||||
cache.class_snapshots.insert(
|
||||
interface.to_string(),
|
||||
TimedClassSnapshot {
|
||||
captured_at: now,
|
||||
snapshot: snapshot.clone(),
|
||||
},
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn parse_live_qdisc_snapshot(raw_json: &str) -> Result<Vec<LiveTcQdiscEntry>, String> {
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(raw_json)
|
||||
.map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
let items = parsed
|
||||
.as_array()
|
||||
.ok_or_else(|| "expected JSON array from tc qdisc show -j".to_string())?;
|
||||
|
||||
let mut entries = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let kind = item
|
||||
.get("kind")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let handle = item
|
||||
.get("handle")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| TcHandle::from_string(value).ok());
|
||||
let parent_raw = item.get("parent").and_then(|value| value.as_str());
|
||||
let parent = parent_raw.and_then(|value| {
|
||||
if value.eq_ignore_ascii_case("root") {
|
||||
None
|
||||
} else {
|
||||
TcHandle::from_string(value).ok()
|
||||
}
|
||||
});
|
||||
let is_root = item
|
||||
.get("root")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
|| matches!(parent_raw, Some(value) if value.eq_ignore_ascii_case("root"));
|
||||
entries.push(LiveTcQdiscEntry {
|
||||
kind,
|
||||
handle,
|
||||
parent,
|
||||
is_root,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_live_class_snapshot(raw: &str) -> Result<HashMap<TcHandle, LiveTcClassEntry>, String> {
|
||||
let mut snapshot = HashMap::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || !trimmed.starts_with("class ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if tokens.len() < 3 {
|
||||
return Err(format!("Malformed tc class line: {trimmed}"));
|
||||
}
|
||||
|
||||
let class_id = TcHandle::from_string(tokens[2]).map_err(|e| {
|
||||
format!(
|
||||
"Invalid tc class handle {:?} in line {:?}: {:?}",
|
||||
tokens[2], trimmed, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut parent = None;
|
||||
let mut leaf_qdisc_major = None;
|
||||
let mut idx = 3usize;
|
||||
while idx < tokens.len() {
|
||||
match tokens[idx] {
|
||||
"parent" if idx + 1 < tokens.len() => {
|
||||
if let Ok(handle) = TcHandle::from_string(tokens[idx + 1]) {
|
||||
parent = Some(handle);
|
||||
}
|
||||
idx += 2;
|
||||
}
|
||||
"leaf" if idx + 1 < tokens.len() => {
|
||||
if let Ok(handle) = TcHandle::from_string(tokens[idx + 1]) {
|
||||
let (major, _) = handle.get_major_minor();
|
||||
if major != 0 {
|
||||
leaf_qdisc_major = Some(major);
|
||||
}
|
||||
}
|
||||
idx += 2;
|
||||
}
|
||||
_ => idx += 1,
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.insert(
|
||||
class_id,
|
||||
LiveTcClassEntry {
|
||||
class_id,
|
||||
parent,
|
||||
leaf_qdisc_major,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn execute_in_memory(command_buffer: &[Vec<String>], purpose: &str) -> ExecuteResult {
|
||||
execute_in_memory_chunked(
|
||||
command_buffer,
|
||||
purpose,
|
||||
command_buffer.len().max(1),
|
||||
None,
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn execute_in_memory_chunked<F>(
|
||||
command_buffer: &[Vec<String>],
|
||||
purpose: &str,
|
||||
chunk_size: usize,
|
||||
memory_guard_min_available_bytes: Option<u64>,
|
||||
mut on_progress: F,
|
||||
) -> ExecuteResult
|
||||
where
|
||||
F: FnMut(usize, usize, usize, usize),
|
||||
{
|
||||
let started = std::time::Instant::now();
|
||||
let _lock = FILE_LOCK.lock();
|
||||
info!(
|
||||
"Bakery: Executing in-memory commands: {} lines, for {purpose}",
|
||||
command_buffer.len()
|
||||
);
|
||||
|
||||
let path = Path::new("/tmp/lqos_bakery_commands.txt");
|
||||
let Some(lines) = write_command_file(path, command_buffer) else {
|
||||
let full_path = Path::new("/tmp/lqos_bakery_commands.txt");
|
||||
let Some(_) = write_command_file(full_path, command_buffer) else {
|
||||
error!("Failed to write commands to file for {purpose}");
|
||||
return;
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(format!("Failed to write commands to file for {purpose}")),
|
||||
};
|
||||
};
|
||||
|
||||
let Ok(output) = std::process::Command::new("/sbin/tc")
|
||||
.args(["-f", "-batch", path.to_str().unwrap_or_default()])
|
||||
.output()
|
||||
else {
|
||||
let message = format!("Failed to execute tc batch command for {purpose}.");
|
||||
error!(message);
|
||||
return;
|
||||
};
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout)
|
||||
.replace("Error: Exclusivity flag on, cannot modify.\n", "");
|
||||
if !output_str.is_empty() {
|
||||
error!("Command output for ({purpose}): {:?}", output_str.trim());
|
||||
if command_buffer.is_empty() {
|
||||
return ExecuteResult {
|
||||
ok: true,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: None,
|
||||
};
|
||||
}
|
||||
|
||||
let error_str = String::from_utf8_lossy(&output.stderr);
|
||||
if !error_str.is_empty() {
|
||||
// Add line numbers to aid debugging with tc -batch's reported line numbers
|
||||
let mut numbered = String::new();
|
||||
for (i, line) in lines.lines().enumerate() {
|
||||
// tc -batch reports 1-based line numbers
|
||||
numbered.push_str(&format!("{:>4}: {}\n", i + 1, line));
|
||||
let chunk_size = chunk_size.max(1);
|
||||
let total_chunks = command_buffer.len().div_ceil(chunk_size);
|
||||
let chunk_path = Path::new("/tmp/lqos_bakery_commands_chunk.txt");
|
||||
let mut completed_commands = 0usize;
|
||||
let mut completed_chunks = 0usize;
|
||||
|
||||
for chunk in command_buffer.chunks(chunk_size) {
|
||||
if let Some(min_available_bytes) = memory_guard_min_available_bytes {
|
||||
match read_memory_snapshot() {
|
||||
Ok(snapshot) if snapshot.available_bytes < min_available_bytes => {
|
||||
let summary = memory_guard_failure_summary(
|
||||
snapshot,
|
||||
min_available_bytes,
|
||||
purpose,
|
||||
completed_chunks + 1,
|
||||
total_chunks,
|
||||
"before",
|
||||
);
|
||||
error!("{summary}");
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(summary),
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
let summary = format!(
|
||||
"Bakery memory guard could not read host memory state before chunk {}/{} for {purpose}: {err}",
|
||||
completed_chunks + 1,
|
||||
total_chunks
|
||||
);
|
||||
error!("{summary}");
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(summary),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
let detailed = format!(
|
||||
"Command error for ({purpose}): {}\nCommands with line numbers:\n{}",
|
||||
error_str.trim(),
|
||||
numbered
|
||||
|
||||
let global_line_start = completed_commands + 1;
|
||||
let Some(lines) = write_command_file(chunk_path, chunk) else {
|
||||
error!("Failed to write chunked commands to file for {purpose}");
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(format!("Failed to write commands to file for {purpose}")),
|
||||
};
|
||||
};
|
||||
|
||||
let output = match run_tc_batch(chunk_path, purpose) {
|
||||
Ok(output) => output,
|
||||
Err(message) => {
|
||||
error!(message);
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(message),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout)
|
||||
.replace("Error: Exclusivity flag on, cannot modify.\n", "");
|
||||
if !output_str.is_empty() {
|
||||
error!("Command output for ({purpose}): {:?}", output_str.trim());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if output.status.success() && !stderr.trim().is_empty() {
|
||||
if tc_success_stderr_is_harmless(stderr.trim()) {
|
||||
debug!("Command stderr for ({purpose}): {:?}", stderr.trim());
|
||||
} else {
|
||||
warn!("Command stderr for ({purpose}): {:?}", stderr.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if tc_batch_failure_is_ignorable_delete_absence(&output, &lines) {
|
||||
debug!(
|
||||
"Bakery tolerated delete-only tc batch absence during {purpose}; targets were already gone"
|
||||
);
|
||||
} else if let Some(failure_summary) = summarize_tc_batch_failure(&output) {
|
||||
let numbered = format_numbered_lines(&lines, global_line_start);
|
||||
let chunk_line_end = global_line_start + chunk.len().saturating_sub(1);
|
||||
let detailed = format!(
|
||||
"Command error for ({purpose}): {}\nFailed chunk {}/{} (global lines {}-{})\nFull batch: {}\nChunk batch: {}\nChunk commands with global line numbers:\n{}",
|
||||
failure_summary,
|
||||
completed_chunks + 1,
|
||||
total_chunks,
|
||||
global_line_start,
|
||||
chunk_line_end,
|
||||
full_path.display(),
|
||||
chunk_path.display(),
|
||||
numbered
|
||||
);
|
||||
error!(detailed);
|
||||
|
||||
let ts = current_timestamp();
|
||||
let path_ts = Path::new("/tmp").join(format!("lqos_bakery_failed_{}.txt", ts));
|
||||
if let Ok(mut f) = File::create(&path_ts) {
|
||||
let _ = f.write_all(detailed.as_bytes());
|
||||
let _ = f.flush();
|
||||
error!(
|
||||
"Bakery wrote numbered command failure to {}",
|
||||
path_ts.display()
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
"Bakery failed to write numbered command failure file: {}",
|
||||
path_ts.display()
|
||||
);
|
||||
}
|
||||
let path_last = Path::new("/tmp/lqos_bakery_last_error.txt");
|
||||
if let Ok(mut f) = File::create(path_last) {
|
||||
let _ = f.write_all(detailed.as_bytes());
|
||||
let _ = f.flush();
|
||||
}
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(failure_summary),
|
||||
};
|
||||
}
|
||||
|
||||
completed_commands += chunk.len();
|
||||
completed_chunks += 1;
|
||||
LIVE_TC_SNAPSHOT_CACHE.lock().invalidate();
|
||||
|
||||
if let Some(min_available_bytes) = memory_guard_min_available_bytes {
|
||||
match read_memory_snapshot() {
|
||||
Ok(snapshot) if snapshot.available_bytes < min_available_bytes => {
|
||||
let summary = memory_guard_failure_summary(
|
||||
snapshot,
|
||||
min_available_bytes,
|
||||
purpose,
|
||||
completed_chunks,
|
||||
total_chunks,
|
||||
"after",
|
||||
);
|
||||
error!("{summary}");
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(summary),
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
let summary = format!(
|
||||
"Bakery memory guard could not read host memory state after chunk {}/{} for {purpose}: {err}",
|
||||
completed_chunks, total_chunks
|
||||
);
|
||||
error!("{summary}");
|
||||
return ExecuteResult {
|
||||
ok: false,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: Some(summary),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on_progress(
|
||||
completed_commands,
|
||||
command_buffer.len(),
|
||||
completed_chunks,
|
||||
total_chunks,
|
||||
);
|
||||
// Log to console
|
||||
error!(detailed);
|
||||
|
||||
// Also persist to /tmp for easier debugging of large batches
|
||||
let ts = current_timestamp();
|
||||
let path_ts = Path::new("/tmp").join(format!("lqos_bakery_failed_{}.txt", ts));
|
||||
if let Ok(mut f) = File::create(&path_ts) {
|
||||
let _ = f.write_all(detailed.as_bytes());
|
||||
let _ = f.flush();
|
||||
error!(
|
||||
"Bakery wrote numbered command failure to {}",
|
||||
path_ts.display()
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
"Bakery failed to write numbered command failure file: {}",
|
||||
path_ts.display()
|
||||
);
|
||||
}
|
||||
// Convenience: keep a rolling 'last error' file
|
||||
let path_last = Path::new("/tmp/lqos_bakery_last_error.txt");
|
||||
if let Ok(mut f) = File::create(path_last) {
|
||||
let _ = f.write_all(detailed.as_bytes());
|
||||
let _ = f.flush();
|
||||
}
|
||||
}
|
||||
|
||||
drop(lock); // Explicitly drop the lock to release it. This happens automatically at the end of the scope, but it's good to be explicit.
|
||||
ExecuteResult {
|
||||
ok: true,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
failure_summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_command_file(path: &Path, commands: &Vec<Vec<String>>) -> Option<String> {
|
||||
pub(crate) fn write_command_file(path: &Path, commands: &[Vec<String>]) -> Option<String> {
|
||||
let mut lines = String::new();
|
||||
let Ok(file) = File::create(path) else {
|
||||
error!("Failed to create output file: {}", path.display());
|
||||
@@ -120,3 +754,195 @@ pub(crate) fn write_command_file(path: &Path, commands: &Vec<Vec<String>>) -> Op
|
||||
}
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
|
||||
fn mock_tc_output(status: i32, stdout: &str, stderr: &str) -> std::process::Output {
|
||||
std::process::Output {
|
||||
status: std::process::ExitStatus::from_raw(status << 8),
|
||||
stdout: stdout.as_bytes().to_vec(),
|
||||
stderr: stderr.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_live_qdisc_snapshot_extracts_root_parent_and_handle_data() {
|
||||
let raw = r#"
|
||||
[
|
||||
{ "kind": "mq", "handle": "7fff:", "parent": "root" },
|
||||
{ "kind": "cake", "handle": "90f1:", "parent": "2:1039" },
|
||||
{ "kind": "fq_codel", "handle": "50c0:", "parent": "8:24dd" },
|
||||
{ "kind": "ingress", "handle": "ffff:", "parent": "ffff:fff1", "root": false },
|
||||
{ "kind": "fq_codel", "handle": "0:", "parent": "3:20" }
|
||||
]
|
||||
"#;
|
||||
let snapshot = parse_live_qdisc_snapshot(raw).expect("snapshot parsed");
|
||||
assert_eq!(snapshot.len(), 5);
|
||||
|
||||
let root = &snapshot[0];
|
||||
assert_eq!(root.kind, "mq");
|
||||
assert_eq!(
|
||||
root.handle,
|
||||
Some(TcHandle::from_string("7fff:").expect("valid"))
|
||||
);
|
||||
assert_eq!(root.parent, None);
|
||||
assert!(root.is_root);
|
||||
|
||||
let child = &snapshot[1];
|
||||
assert_eq!(child.kind, "cake");
|
||||
assert_eq!(
|
||||
child.parent,
|
||||
Some(TcHandle::from_string("2:1039").expect("valid"))
|
||||
);
|
||||
assert!(!child.is_root);
|
||||
|
||||
let zero_handle = &snapshot[4];
|
||||
assert_eq!(
|
||||
zero_handle.handle,
|
||||
Some(TcHandle::from_string("0:").expect("valid"))
|
||||
);
|
||||
assert_eq!(
|
||||
zero_handle.parent,
|
||||
Some(TcHandle::from_string("3:20").expect("valid"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_live_qdisc_handle_majors_collects_non_zero_handles_from_snapshot() {
|
||||
let raw = r#"
|
||||
[
|
||||
{ "kind": "mq", "handle": "7fff:", "parent": "root" },
|
||||
{ "kind": "cake", "handle": "90f1:", "parent": "2:1039" },
|
||||
{ "kind": "fq_codel", "handle": "50c0:", "parent": "8:24dd" },
|
||||
{ "kind": "ingress", "handle": "ffff:", "parent": "ffff:fff1" },
|
||||
{ "kind": "fq_codel", "handle": "0:", "parent": "3:20" }
|
||||
]
|
||||
"#;
|
||||
let snapshot = parse_live_qdisc_snapshot(raw).expect("snapshot parsed");
|
||||
let handles: HashSet<u16> = snapshot
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.handle)
|
||||
.filter_map(|handle| {
|
||||
let (major, _) = handle.get_major_minor();
|
||||
(major != 0).then_some(major)
|
||||
})
|
||||
.collect();
|
||||
assert!(handles.contains(&0x7fff));
|
||||
assert!(handles.contains(&0x90f1));
|
||||
assert!(handles.contains(&0x50c0));
|
||||
assert!(handles.contains(&0xffff));
|
||||
assert!(!handles.contains(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_live_qdisc_snapshot_rejects_non_arrays() {
|
||||
let err =
|
||||
parse_live_qdisc_snapshot(r#"{"handle":"90f1:"}"#).expect_err("non-array should fail");
|
||||
assert!(err.contains("expected JSON array"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_live_class_snapshot_extracts_parent_and_leaf() {
|
||||
let raw = "\
|
||||
class htb 1:da parent 1:4 leaf ddad: prio 3 rate 20Mbit ceil 100Mbit burst 1600b cburst 1600b
|
||||
class htb 1:4 root rate 949Mbit ceil 950Mbit burst 1423b cburst 1425b
|
||||
";
|
||||
let snapshot = parse_live_class_snapshot(raw).expect("snapshot parsed");
|
||||
let class_da = snapshot
|
||||
.get(&TcHandle::from_string("1:da").expect("valid class"))
|
||||
.expect("class 1:da present");
|
||||
assert_eq!(
|
||||
class_da.parent,
|
||||
Some(TcHandle::from_string("1:4").expect("valid parent"))
|
||||
);
|
||||
assert_eq!(class_da.leaf_qdisc_major, Some(0xddad));
|
||||
|
||||
let class_root = snapshot
|
||||
.get(&TcHandle::from_string("1:4").expect("valid class"))
|
||||
.expect("class 1:4 present");
|
||||
assert_eq!(class_root.parent, None);
|
||||
assert_eq!(class_root.leaf_qdisc_major, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_snapshot_extracts_total_and_available() {
|
||||
let raw = "\
|
||||
MemTotal: 32768000 kB
|
||||
MemFree: 1024000 kB
|
||||
MemAvailable: 8192000 kB
|
||||
";
|
||||
let snapshot = parse_memory_snapshot(raw).expect("meminfo parsed");
|
||||
assert_eq!(snapshot.total_bytes, 32_768_000_u64 * 1024);
|
||||
assert_eq!(snapshot.available_bytes, 8_192_000_u64 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_snapshot_requires_memavailable() {
|
||||
let raw = "\
|
||||
MemTotal: 32768000 kB
|
||||
MemFree: 1024000 kB
|
||||
";
|
||||
let err = parse_memory_snapshot(raw).expect_err("memavailable should be required");
|
||||
assert!(err.contains("MemAvailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tc_batch_failure_rejects_nonzero_exit_without_stderr() {
|
||||
let output = mock_tc_output(1, "", "");
|
||||
let summary = summarize_tc_batch_failure(&output).expect("nonzero exit should fail");
|
||||
assert!(summary.contains("status 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tc_batch_failure_includes_stderr_with_exit_status() {
|
||||
let output = mock_tc_output(2, "", "RTNETLINK answers: Invalid argument\n");
|
||||
let summary = summarize_tc_batch_failure(&output).expect("stderr failure should be kept");
|
||||
assert!(summary.contains("status 2"));
|
||||
assert!(summary.contains("Invalid argument"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tc_batch_failure_accepts_clean_success() {
|
||||
let output = mock_tc_output(0, "", "");
|
||||
assert!(summarize_tc_batch_failure(&output).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tc_batch_failure_accepts_success_with_stderr_warning() {
|
||||
let output = mock_tc_output(0, "", "Warning: sch_htb: quantum of class 10134 is big.\n");
|
||||
assert!(summarize_tc_batch_failure(&output).is_none());
|
||||
assert!(tc_success_stderr_is_harmless(
|
||||
"Warning: sch_htb: quantum of class 10134 is big.\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harmless_success_stderr_rejects_non_warning_lines() {
|
||||
assert!(!tc_success_stderr_is_harmless(
|
||||
"RTNETLINK answers: No such file or directory\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignorable_delete_absence_accepts_missing_delete_targets() {
|
||||
let output = mock_tc_output(
|
||||
1,
|
||||
"",
|
||||
"Error: Specified class not found.\nCommand failed /tmp/x:1\nRTNETLINK answers: No such file or directory\nCommand failed /tmp/x:2\n",
|
||||
);
|
||||
let lines = "qdisc del dev if0 parent 0x1:0x2000\nclass del dev if0 parent 0x1:0x3 classid 0x1:0x2000\n";
|
||||
assert!(tc_batch_failure_is_ignorable_delete_absence(&output, lines));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignorable_delete_absence_rejects_non_delete_failures() {
|
||||
let output = mock_tc_output(1, "", "Error: HTB class in use.\n");
|
||||
let lines = "class del dev if0 parent 0x1:0x35 classid 0x1:0x39\n";
|
||||
assert!(!tc_batch_failure_is_ignorable_delete_absence(
|
||||
&output, lines
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,15 @@ mod unix_socket_server;
|
||||
pub use client::{LibreqosBusClient, bus_request};
|
||||
pub use queue_data::*;
|
||||
pub use reply::BusReply;
|
||||
pub use request::{BlackboardSystem, BusRequest, TopFlowType, UrgentSeverity, UrgentSource};
|
||||
pub use request::{
|
||||
BakeryCapacityReportInterface, BlackboardSystem, BusRequest, TopFlowType, UrgentSeverity,
|
||||
UrgentSource,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use response::{
|
||||
AsnHeatmapData, BakeryStatsSnapshot, BusResponse, CircuitHeatmapData, SiteHeatmapData,
|
||||
StormguardDebugDirection, StormguardDebugEntry, UrgentIssue,
|
||||
StormguardDebugDirection, StormguardDebugEntry, TreeGuardRuntimeNodeBranchSnapshot,
|
||||
TreeGuardRuntimeNodeOperationSnapshot, UrgentIssue,
|
||||
};
|
||||
pub use session::BusSession;
|
||||
use thiserror::Error;
|
||||
@@ -30,20 +34,28 @@ pub const BUS_SOCKET_PATH: &str = "/run/lqos/bus";
|
||||
/// that the directory exists.
|
||||
pub(crate) const BUS_SOCKET_DIRECTORY: &str = "/run/lqos";
|
||||
|
||||
/// Errors returned by local bus clients while connecting to or communicating
|
||||
/// with the `lqosd` Unix socket.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BusClientError {
|
||||
/// The local bus socket does not yet exist or is not reachable.
|
||||
#[error(
|
||||
"Socket (typically /run/lqos/bus) not found. Check that lqosd is running, and you have permission to access the socket path."
|
||||
)]
|
||||
SocketNotFound,
|
||||
/// The request could not be encoded for transport.
|
||||
#[error("Unable to encode request")]
|
||||
EncodingError,
|
||||
/// The reply could not be decoded from the socket stream.
|
||||
#[error("Unable to decode request")]
|
||||
DecodingError,
|
||||
/// Writing the request to the socket failed.
|
||||
#[error("Cannot write to socket")]
|
||||
StreamWriteError,
|
||||
/// Reading the reply from the socket failed.
|
||||
#[error("Cannot read from socket")]
|
||||
StreamReadError,
|
||||
/// The socket connection is no longer usable.
|
||||
#[error("Stream is no longer connected")]
|
||||
StreamNotConnected,
|
||||
}
|
||||
@@ -6,6 +6,23 @@ use allocative::Allocative;
|
||||
use lqos_config::Tunables;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-interface Bakery qdisc-budget report entry.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)]
|
||||
pub struct BakeryCapacityReportInterface {
|
||||
/// Interface name, e.g. `ens19`.
|
||||
pub name: String,
|
||||
/// Planned qdisc count for the interface.
|
||||
pub planned_qdiscs: usize,
|
||||
/// Planned infrastructure qdiscs (`mq`, `htb`, etc.) for the interface.
|
||||
pub infra_qdiscs: usize,
|
||||
/// Planned `cake` leaf qdiscs for the interface.
|
||||
pub cake_qdiscs: usize,
|
||||
/// Planned `fq_codel` leaf qdiscs for the interface.
|
||||
pub fq_codel_qdiscs: usize,
|
||||
/// Estimated kernel memory cost for the interface's planned qdiscs.
|
||||
pub estimated_memory_bytes: u64,
|
||||
}
|
||||
|
||||
/// Source system for an urgent issue
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Copy, Allocative)]
|
||||
pub enum UrgentSource {
|
||||
@@ -182,6 +199,9 @@ pub enum BusRequest {
|
||||
/// Requests that the configuration be updated
|
||||
UpdateLqosdConfig(Box<lqos_config::Config>),
|
||||
|
||||
/// Invalidate cached Node Manager authentication state.
|
||||
InvalidateAuthCache,
|
||||
|
||||
/// Request that we start watching a circuit's queue
|
||||
WatchQueue(String),
|
||||
|
||||
@@ -327,6 +347,10 @@ pub enum BusRequest {
|
||||
BakeryAddCircuit {
|
||||
/// The circuit hash, which is a unique identifier for the circuit
|
||||
circuit_hash: i64,
|
||||
/// Human-readable circuit name when known at queue-build time.
|
||||
circuit_name: Option<String>,
|
||||
/// Human-readable parent site/node name when known at queue-build time.
|
||||
site_name: Option<String>,
|
||||
/// The parent class ID for the circuit
|
||||
parent_class_id: TcHandle,
|
||||
/// The upload parent class ID for the circuit
|
||||
@@ -360,6 +384,57 @@ pub enum BusRequest {
|
||||
/// Get current Bakery statistics
|
||||
GetBakeryStats,
|
||||
|
||||
/// Report the most recent Bakery qdisc-budget preflight result.
|
||||
BakeryReportPreflight {
|
||||
/// Whether the planned full reload fits within the current safe budget.
|
||||
ok: bool,
|
||||
/// Operator-facing summary of the preflight result.
|
||||
message: String,
|
||||
/// Safe operational budget used for the preflight.
|
||||
safe_budget: usize,
|
||||
/// Hard kernel limit for reference.
|
||||
hard_limit: usize,
|
||||
/// Estimated total kernel memory cost of the planned qdisc model.
|
||||
estimated_total_memory_bytes: u64,
|
||||
/// Current host memory available during preflight, if known.
|
||||
memory_available_bytes: Option<u64>,
|
||||
/// Memory floor that must remain available before the apply proceeds.
|
||||
memory_guard_min_available_bytes: u64,
|
||||
/// Whether the memory preflight passed.
|
||||
memory_ok: bool,
|
||||
/// Per-interface planned qdisc counts.
|
||||
interfaces: Vec<BakeryCapacityReportInterface>,
|
||||
},
|
||||
|
||||
/// Request a live Bakery runtime node virtualization or restore for a named node.
|
||||
///
|
||||
/// This is intended for local daemon-side orchestration and tests that need a deterministic
|
||||
/// runtime node operation without waiting for TreeGuard policy to fire.
|
||||
TreeGuardSetNodeVirtual {
|
||||
/// Exact node name from `network.json`.
|
||||
node_name: String,
|
||||
/// `true` to runtime-virtualize, `false` to restore.
|
||||
virtualized: bool,
|
||||
},
|
||||
|
||||
/// Request the latest Bakery runtime node-operation snapshot for a named node.
|
||||
///
|
||||
/// This is intended for local daemon-side orchestration and tests that need to observe whether
|
||||
/// a runtime node operation was deferred, is still applying, or failed after acceptance.
|
||||
TreeGuardGetNodeVirtualStatus {
|
||||
/// Exact node name from `network.json`.
|
||||
node_name: String,
|
||||
},
|
||||
|
||||
/// Request the latest Bakery runtime branch-state snapshot for a named node.
|
||||
///
|
||||
/// This is intended for local daemon-side orchestration and tests that need to verify which
|
||||
/// retained branch is active after a runtime virtualization or restore.
|
||||
TreeGuardGetNodeVirtualBranchState {
|
||||
/// Exact node name from `network.json`.
|
||||
node_name: String,
|
||||
},
|
||||
|
||||
/// Announce that the API is ready
|
||||
ApiReady,
|
||||
|
||||
|
||||
@@ -48,6 +48,56 @@ pub struct BakeryStatsSnapshot {
|
||||
pub active_circuits: u64,
|
||||
}
|
||||
|
||||
/// Serializable snapshot of a Bakery-tracked TreeGuard runtime node operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Allocative)]
|
||||
pub struct TreeGuardRuntimeNodeOperationSnapshot {
|
||||
/// Monotonic Bakery-local operation identifier.
|
||||
pub operation_id: u64,
|
||||
/// Stable Bakery site hash derived from the node name.
|
||||
pub site_hash: i64,
|
||||
/// Requested runtime action (`virtualize` or `restore`).
|
||||
pub action: String,
|
||||
/// Current operation status.
|
||||
pub status: String,
|
||||
/// Number of retry/apply attempts performed so far.
|
||||
pub attempt_count: u32,
|
||||
/// Unix timestamp when the operation was submitted.
|
||||
pub submitted_at_unix: u64,
|
||||
/// Unix timestamp when the operation last changed state.
|
||||
pub updated_at_unix: u64,
|
||||
/// Optional unix timestamp for the next retry, if waiting.
|
||||
pub next_retry_at_unix: Option<u64>,
|
||||
/// Last error observed by Bakery for this operation, if any.
|
||||
pub last_error: Option<String>,
|
||||
/// Structured failure reason observed by Bakery for this operation, if any.
|
||||
pub failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Serializable snapshot of Bakery's retained runtime branch state for a TreeGuard node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Allocative)]
|
||||
pub struct TreeGuardRuntimeNodeBranchSnapshot {
|
||||
/// Stable Bakery site hash derived from the node name.
|
||||
pub site_hash: i64,
|
||||
/// Which retained branch is currently active (`shadow` or `original`).
|
||||
pub active_branch: String,
|
||||
/// Current runtime lifecycle label for the node.
|
||||
pub lifecycle: String,
|
||||
/// Whether Bakery is still waiting to prune the inactive branch.
|
||||
pub pending_prune: bool,
|
||||
/// Optional unix timestamp for the next prune/cleanup retry, if one is pending.
|
||||
pub next_prune_attempt_unix: Option<u64>,
|
||||
/// Hashes of branch sites currently marked active.
|
||||
pub active_site_hashes: Vec<i64>,
|
||||
/// Hashes of original/saved sites retained for restore logic.
|
||||
pub saved_site_hashes: Vec<i64>,
|
||||
/// Hashes of inactive-branch sites queued for pruning.
|
||||
pub prune_site_hashes: Vec<i64>,
|
||||
/// Observed downlink qdisc major for the runtime root, if known.
|
||||
pub qdisc_down_major: Option<u16>,
|
||||
/// Observed uplink qdisc major for the runtime root, if known.
|
||||
pub qdisc_up_major: Option<u16>,
|
||||
}
|
||||
|
||||
/// Circuit-level TemporalHeatmap data for the executive summary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Allocative)]
|
||||
pub struct CircuitHeatmapData {
|
||||
@@ -111,6 +161,14 @@ pub struct ExecutiveSummaryHeader {
|
||||
pub htb_queue_count: u64,
|
||||
/// Number of CAKE queues being tracked.
|
||||
pub cake_queue_count: u64,
|
||||
/// Number of fq_codel queues being tracked.
|
||||
pub fq_codel_queue_count: u64,
|
||||
/// Whether the displayed queue counts are intentionally held at their last-known values.
|
||||
#[serde(default)]
|
||||
pub queue_stats_stale: bool,
|
||||
/// Whether Bakery is currently applying a full reload batch.
|
||||
#[serde(default)]
|
||||
pub bakery_reload_in_progress: bool,
|
||||
/// Whether Insight is connected.
|
||||
pub insight_connected: bool,
|
||||
}
|
||||
@@ -625,4 +683,10 @@ pub enum BusResponse {
|
||||
|
||||
/// Insight license summary (licensed + optional max circuits).
|
||||
InsightLicenseSummary(InsightLicenseSummary),
|
||||
|
||||
/// Latest Bakery runtime node-operation snapshot for a named TreeGuard node, if any.
|
||||
TreeGuardRuntimeNodeOperation(Option<TreeGuardRuntimeNodeOperationSnapshot>),
|
||||
|
||||
/// Latest Bakery runtime branch-state snapshot for a named TreeGuard node, if any.
|
||||
TreeGuardRuntimeNodeBranch(Option<TreeGuardRuntimeNodeBranchSnapshot>),
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::TcHandle;
|
||||
use allocative::Allocative;
|
||||
use lqos_utils::units::DownUpOrder;
|
||||
use lqos_utils::units::{DownUpOrder, TcpRetransmitSample};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -30,8 +30,8 @@ pub struct IpStats {
|
||||
/// Associated TC traffic control handle.
|
||||
pub tc_handle: TcHandle,
|
||||
|
||||
/// TCP Retransmits for this host at the current time.
|
||||
pub tcp_retransmits: (f64, f64),
|
||||
/// TCP retransmit samples for this host at the current time.
|
||||
pub tcp_retransmit_sample: DownUpOrder<TcpRetransmitSample>,
|
||||
}
|
||||
|
||||
/// Represents an IP Mapping in the XDP IP to TC/CPU mapping system.
|
||||
@@ -224,10 +224,8 @@ pub struct Circuit {
|
||||
/// QoO score (0..100), per direction.
|
||||
#[serde(default)]
|
||||
pub qoo: DownUpOrder<Option<f32>>,
|
||||
/// TCP Retransmits for this host at the current time.
|
||||
pub tcp_retransmits: DownUpOrder<u64>,
|
||||
/// The number of TCP packets per second.
|
||||
pub tcp_packets: DownUpOrder<u64>,
|
||||
/// TCP retransmit samples for this host at the current time.
|
||||
pub tcp_retransmit_sample: DownUpOrder<TcpRetransmitSample>,
|
||||
/// The mapped circuit ID
|
||||
pub circuit_id: Option<String>,
|
||||
/// The mapped device ID
|
||||
|
||||
@@ -29,12 +29,14 @@ pub use bus::response::{
|
||||
CircuitHeatmapData, CountryListEntry, DeviceCounts, ExecutiveSummaryHeader, FlowMapPoint,
|
||||
FlowTimelineEntry, InsightLicenseSummary, NodeCapacity, ProtocolListEntry, QueueStatsTotal,
|
||||
RetransmitSummary, SchedulerDetails, SearchResultEntry, SiteHeatmapData,
|
||||
StormguardDebugDirection, StormguardDebugEntry, UrgentIssue, WarningLevel,
|
||||
StormguardDebugDirection, StormguardDebugEntry, TreeGuardRuntimeNodeBranchSnapshot,
|
||||
TreeGuardRuntimeNodeOperationSnapshot, UrgentIssue, WarningLevel,
|
||||
};
|
||||
pub use bus::{
|
||||
BUS_SOCKET_PATH, BlackboardSystem, BusReply, BusRequest, BusResponse, BusSession,
|
||||
CakeDiffTinTransit, CakeDiffTransit, CakeTransit, LibreqosBusClient, QueueStoreTransit,
|
||||
TopFlowType, UnixSocketServer, UrgentSeverity, UrgentSource, bus_request,
|
||||
BUS_SOCKET_PATH, BakeryCapacityReportInterface, BlackboardSystem, BusClientError, BusReply,
|
||||
BusRequest, BusResponse, BusSession, CakeDiffTinTransit, CakeDiffTransit, CakeTransit,
|
||||
LibreqosBusClient, QueueStoreTransit, TopFlowType, UnixSocketServer, UrgentSeverity,
|
||||
UrgentSource, bus_request,
|
||||
};
|
||||
pub use tc_handle::TcHandle;
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ csv = { workspace = true }
|
||||
ip_network_table = { workspace = true }
|
||||
ip_network = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand_core = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
//! local web UI on LibreQoS boxes. It maps to `/<install dir>/lqusers.toml`
|
||||
|
||||
use allocative::Allocative;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
fs::{OpenOptions, read_to_string, remove_file},
|
||||
fs::{OpenOptions, read_to_string, remove_file, rename},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
@@ -14,6 +16,21 @@ use thiserror::Error;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
const AUTH_FILE_VERSION: u32 = 2;
|
||||
const LEGACY_AUTH_FILE_VERSION: u32 = 1;
|
||||
const INITIAL_AUTH_EPOCH: u64 = 1;
|
||||
const LEGACY_AUTH_FILE_NAME: &str = "webusers.toml";
|
||||
const CURRENT_AUTH_FILE_NAME: &str = "lqusers.toml";
|
||||
const LEGACY_PASSWORD_PEPPER: &str = "_LibreQosLikesPasswordsForDinner";
|
||||
|
||||
fn default_auth_file_version() -> u32 {
|
||||
LEGACY_AUTH_FILE_VERSION
|
||||
}
|
||||
|
||||
fn default_auth_epoch() -> u64 {
|
||||
INITIAL_AUTH_EPOCH
|
||||
}
|
||||
|
||||
/// Access rights of a user
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Allocative)]
|
||||
pub enum UserRole {
|
||||
@@ -59,29 +76,91 @@ impl Display for UserRole {
|
||||
pub struct WebUser {
|
||||
/// The user's username.
|
||||
pub username: String,
|
||||
/// The user's password hash.
|
||||
/// The user's password hash. This may be a legacy SHA-256 hash or an
|
||||
/// Argon2id PHC string until the user next logs in.
|
||||
pub password_hash: String,
|
||||
/// The user's role.
|
||||
pub role: UserRole,
|
||||
/// The user's token.
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Result of authenticating a single user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthenticatedUser {
|
||||
/// The authenticated username.
|
||||
pub username: String,
|
||||
/// The user's role.
|
||||
pub role: UserRole,
|
||||
/// Current auth epoch after any migration-side rewrite.
|
||||
pub auth_epoch: u64,
|
||||
/// True when a legacy password hash was upgraded to Argon2id.
|
||||
pub password_upgraded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct PasswordVerification {
|
||||
valid: bool,
|
||||
needs_rehash: bool,
|
||||
}
|
||||
|
||||
/// Container holding the authorized web users.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Default, Allocative)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Allocative)]
|
||||
pub struct WebUsers {
|
||||
#[serde(default = "default_auth_file_version")]
|
||||
version: u32,
|
||||
#[serde(default = "default_auth_epoch")]
|
||||
auth_epoch: u64,
|
||||
#[serde(default)]
|
||||
allow_unauthenticated_to_view: bool,
|
||||
#[serde(default)]
|
||||
users: Vec<WebUser>,
|
||||
}
|
||||
|
||||
impl Default for WebUsers {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: AUTH_FILE_VERSION,
|
||||
auth_epoch: INITIAL_AUTH_EPOCH,
|
||||
allow_unauthenticated_to_view: false,
|
||||
users: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebUsers {
|
||||
fn path() -> Result<PathBuf, AuthenticationError> {
|
||||
fn base_path() -> Result<PathBuf, AuthenticationError> {
|
||||
let base_path = crate::load_config()
|
||||
.map_err(|_| AuthenticationError::UnableToLoadEtcLqos)?
|
||||
.lqos_directory
|
||||
.clone();
|
||||
let filename = Path::new(&base_path).join("lqusers.toml");
|
||||
Ok(filename)
|
||||
Ok(PathBuf::from(base_path))
|
||||
}
|
||||
|
||||
fn primary_path() -> Result<PathBuf, AuthenticationError> {
|
||||
Ok(Self::base_path()?.join(CURRENT_AUTH_FILE_NAME))
|
||||
}
|
||||
|
||||
fn legacy_path() -> Result<PathBuf, AuthenticationError> {
|
||||
Ok(Self::base_path()?.join(LEGACY_AUTH_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Returns the current `lqusers.toml` path.
|
||||
pub fn path() -> Result<PathBuf, AuthenticationError> {
|
||||
Self::primary_path()
|
||||
}
|
||||
|
||||
/// Returns the existing auth file path, checking the legacy filename as a fallback.
|
||||
pub fn existing_path() -> Result<Option<PathBuf>, AuthenticationError> {
|
||||
let current = Self::primary_path()?;
|
||||
if current.exists() {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
let legacy = Self::legacy_path()?;
|
||||
if legacy.exists() {
|
||||
return Ok(Some(legacy));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Is the list of users empty?
|
||||
@@ -89,66 +168,156 @@ impl WebUsers {
|
||||
self.users.is_empty()
|
||||
}
|
||||
|
||||
fn normalize_for_save(&self) -> Self {
|
||||
let mut normalized = self.clone();
|
||||
normalized.version = AUTH_FILE_VERSION;
|
||||
if normalized.auth_epoch == 0 {
|
||||
normalized.auth_epoch = INITIAL_AUTH_EPOCH;
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn save_to_disk(&self) -> Result<(), AuthenticationError> {
|
||||
let path = Self::path()?;
|
||||
let new_contents =
|
||||
toml_edit::ser::to_string(&self).map_err(AuthenticationError::SerializationError)?;
|
||||
if path.exists() && remove_file(&path).is_err() {
|
||||
error!("Unable to delete web users file");
|
||||
return Err(AuthenticationError::UnableToDelete);
|
||||
let path = Self::primary_path()?;
|
||||
let tmp_path = path.with_extension(format!("toml.tmp-{}", Uuid::new_v4()));
|
||||
let normalized = self.normalize_for_save();
|
||||
let new_contents = toml_edit::ser::to_string(&normalized)
|
||||
.map_err(AuthenticationError::SerializationError)?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&tmp_path)
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"Unable to open temporary auth file {:?} for writing: {e}",
|
||||
tmp_path
|
||||
);
|
||||
AuthenticationError::UnableToWrite
|
||||
})?;
|
||||
file.write_all(new_contents.as_bytes()).map_err(|e| {
|
||||
error!("Unable to write temporary auth file {:?}: {e}", tmp_path);
|
||||
AuthenticationError::UnableToWrite
|
||||
})?;
|
||||
drop(file);
|
||||
|
||||
rename(&tmp_path, &path).map_err(|e| {
|
||||
error!(
|
||||
"Unable to rename temporary auth file {:?} to {:?}: {e}",
|
||||
tmp_path, path
|
||||
);
|
||||
let _ = remove_file(&tmp_path);
|
||||
AuthenticationError::UnableToWrite
|
||||
})?;
|
||||
|
||||
let legacy_path = Self::legacy_path()?;
|
||||
if legacy_path != path
|
||||
&& legacy_path.exists()
|
||||
&& let Err(e) = remove_file(&legacy_path)
|
||||
{
|
||||
warn!("Unable to remove legacy auth file {:?}: {e}", legacy_path);
|
||||
}
|
||||
if let Ok(mut file) = OpenOptions::new().write(true).create_new(true).open(path) {
|
||||
if file.write_all(new_contents.as_bytes()).is_err() {
|
||||
error!("Unable to write web users file to disk.");
|
||||
return Err(AuthenticationError::UnableToWrite);
|
||||
}
|
||||
} else {
|
||||
error!("Unable to open web users file for writing.");
|
||||
return Err(AuthenticationError::UnableToWrite);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_if_needed(&mut self, loaded_from: &Path) -> Result<(), AuthenticationError> {
|
||||
let current_path = Self::primary_path()?;
|
||||
let loaded_from_legacy_path = loaded_from != current_path;
|
||||
let mut needs_rewrite = false;
|
||||
|
||||
if self.version != AUTH_FILE_VERSION {
|
||||
self.version = AUTH_FILE_VERSION;
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if self.auth_epoch == 0 {
|
||||
self.auth_epoch = INITIAL_AUTH_EPOCH;
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if loaded_from_legacy_path {
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if needs_rewrite {
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does the user's file exist? True if it does, false otherwise.
|
||||
pub fn does_users_file_exist() -> Result<bool, AuthenticationError> {
|
||||
Ok(Self::path()?.exists())
|
||||
Ok(Self::existing_path()?.is_some())
|
||||
}
|
||||
|
||||
/// Try to load `lqusers.toml`. If it is unavailable, create a new--empty--
|
||||
/// file.
|
||||
/// Try to load `lqusers.toml`, creating a new version 2 file if no auth file exists.
|
||||
pub fn load_or_create() -> Result<Self, AuthenticationError> {
|
||||
let path = Self::path()?;
|
||||
if !path.exists() {
|
||||
// Create a new users file, save it and return the
|
||||
// empty file
|
||||
if let Some(path) = Self::existing_path()? {
|
||||
let raw = read_to_string(&path).map_err(|e| {
|
||||
error!("Unable to read auth file {:?}: {e}", path);
|
||||
AuthenticationError::UnableToRead
|
||||
})?;
|
||||
let mut users: Self = toml_edit::de::from_str(&raw).map_err(|e| {
|
||||
error!("Unable to deserialize auth file {:?}: {e}", path);
|
||||
AuthenticationError::UnableToParse
|
||||
})?;
|
||||
users.migrate_if_needed(&path)?;
|
||||
Ok(users)
|
||||
} else {
|
||||
let new_users = Self::default();
|
||||
new_users.save_to_disk()?;
|
||||
Ok(new_users)
|
||||
} else {
|
||||
// Load from disk
|
||||
if let Ok(raw) = read_to_string(path) {
|
||||
let parse_result = toml_edit::de::from_str(&raw);
|
||||
if let Ok(users) = parse_result {
|
||||
Ok(users)
|
||||
} else {
|
||||
error!("Unable to deserialize lqusers.toml. Error in next message.");
|
||||
error!("{:?}", parse_result);
|
||||
Err(AuthenticationError::UnableToParse)
|
||||
}
|
||||
} else {
|
||||
error!("Unable to read lqusers.toml");
|
||||
Err(AuthenticationError::UnableToRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> String {
|
||||
let salted = format!("!x{password}_LibreQosLikesPasswordsForDinner");
|
||||
fn hash_password(password: &str) -> Result<String, AuthenticationError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(AuthenticationError::PasswordHashError)
|
||||
}
|
||||
|
||||
fn hash_password_legacy(password: &str) -> String {
|
||||
let salted = format!("!x{password}{LEGACY_PASSWORD_PEPPER}");
|
||||
let mut sha256 = Sha256::new();
|
||||
sha256.update(salted);
|
||||
format!("{:X}", sha256.finalize())
|
||||
}
|
||||
|
||||
fn verify_password(
|
||||
password: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<PasswordVerification, AuthenticationError> {
|
||||
if password_hash.starts_with("$argon2") {
|
||||
let parsed =
|
||||
PasswordHash::new(password_hash).map_err(AuthenticationError::PasswordHashError)?;
|
||||
Ok(PasswordVerification {
|
||||
valid: Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
needs_rehash: false,
|
||||
})
|
||||
} else {
|
||||
Ok(PasswordVerification {
|
||||
valid: Self::hash_password_legacy(password) == password_hash,
|
||||
needs_rehash: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn bump_auth_epoch(&mut self) {
|
||||
self.auth_epoch = self.auth_epoch.saturating_add(1).max(INITIAL_AUTH_EPOCH);
|
||||
}
|
||||
|
||||
/// Returns the current auth epoch used to revoke signed sessions.
|
||||
pub fn auth_epoch(&self) -> u64 {
|
||||
self.auth_epoch
|
||||
}
|
||||
|
||||
/// If a user exists with this username, update their details to the
|
||||
/// provided values. If the user does not exist, create them with the
|
||||
/// provided values.
|
||||
@@ -157,25 +326,23 @@ impl WebUsers {
|
||||
username: &str,
|
||||
password: &str,
|
||||
role: UserRole,
|
||||
) -> Result<String, AuthenticationError> {
|
||||
let token; // Assigned in a branch
|
||||
) -> Result<(), AuthenticationError> {
|
||||
let password_hash = Self::hash_password(password)?;
|
||||
if let Some(user) = self.users.iter_mut().find(|u| u.username == username) {
|
||||
user.password_hash = Self::hash_password(password);
|
||||
user.password_hash = password_hash;
|
||||
user.role = role;
|
||||
token = user.token.clone();
|
||||
} else {
|
||||
token = Uuid::new_v4().to_string();
|
||||
let new_user = WebUser {
|
||||
username: username.to_string(),
|
||||
password_hash: Self::hash_password(password),
|
||||
password_hash,
|
||||
role,
|
||||
token: token.clone(),
|
||||
};
|
||||
self.users.push(new_user);
|
||||
}
|
||||
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
Ok(token)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an existing user, optionally changing their password.
|
||||
@@ -189,20 +356,18 @@ impl WebUsers {
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
role: UserRole,
|
||||
) -> Result<String, AuthenticationError> {
|
||||
let token;
|
||||
if let Some(user) = self.users.iter_mut().find(|u| u.username == username) {
|
||||
if let Some(password) = password {
|
||||
user.password_hash = Self::hash_password(password);
|
||||
}
|
||||
user.role = role;
|
||||
token = user.token.clone();
|
||||
} else {
|
||||
) -> Result<(), AuthenticationError> {
|
||||
let Some(user) = self.users.iter_mut().find(|u| u.username == username) else {
|
||||
return Err(AuthenticationError::UserNotFound);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(password) = password {
|
||||
user.password_hash = Self::hash_password(password)?;
|
||||
}
|
||||
user.role = role;
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
Ok(token)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a user from `lqusers.toml`
|
||||
@@ -213,48 +378,42 @@ impl WebUsers {
|
||||
error!("User {username} not found, hence not deleted.");
|
||||
return Err(AuthenticationError::UserNotFound);
|
||||
}
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt a login with the specified username and password. If
|
||||
/// the login succeeds, returns the publically shareable token that
|
||||
/// uniquely identifies the user a a string. If it fails, returns an
|
||||
/// `Err`.
|
||||
pub fn login(&self, username: &str, password: &str) -> Result<String, AuthenticationError> {
|
||||
let hash = Self::hash_password(password);
|
||||
if let Some(user) = self
|
||||
.users
|
||||
.iter()
|
||||
.find(|u| u.username == username && u.password_hash == hash)
|
||||
{
|
||||
Ok(user.token.clone())
|
||||
} else if self.allow_unauthenticated_to_view {
|
||||
Ok("default".to_string())
|
||||
} else {
|
||||
Err(AuthenticationError::InvalidLogin)
|
||||
}
|
||||
}
|
||||
/// Attempt a login with the specified username and password. If the login
|
||||
/// succeeds, returns the authenticated user details and transparently
|
||||
/// upgrades legacy password hashes to Argon2id.
|
||||
pub fn authenticate(
|
||||
&mut self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthenticatedUser, AuthenticationError> {
|
||||
let Some(index) = self.users.iter().position(|u| u.username == username) else {
|
||||
return Err(AuthenticationError::InvalidLogin);
|
||||
};
|
||||
|
||||
/// Given a token, lookup the matching user and return their role.
|
||||
pub fn get_role_from_token(&self, token: &str) -> Result<UserRole, AuthenticationError> {
|
||||
if let Some(user) = self.users.iter().find(|u| u.token == token) {
|
||||
Ok(user.role)
|
||||
} else if self.allow_unauthenticated_to_view {
|
||||
Ok(UserRole::ReadOnly)
|
||||
} else {
|
||||
warn!("Token {token} not found, invalid data access attempt.");
|
||||
Err(AuthenticationError::InvalidToken)
|
||||
let verification = Self::verify_password(password, &self.users[index].password_hash)?;
|
||||
if !verification.valid {
|
||||
return Err(AuthenticationError::InvalidLogin);
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a token, lookup the matching user and return their username.
|
||||
pub fn get_username(&self, token: &str) -> String {
|
||||
if let Some(user) = self.users.iter().find(|u| u.token == token) {
|
||||
user.username.clone()
|
||||
} else {
|
||||
"Anonymous".to_string()
|
||||
let mut password_upgraded = false;
|
||||
if verification.needs_rehash {
|
||||
self.users[index].password_hash = Self::hash_password(password)?;
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
password_upgraded = true;
|
||||
}
|
||||
|
||||
Ok(AuthenticatedUser {
|
||||
username: self.users[index].username.clone(),
|
||||
role: self.users[index].role,
|
||||
auth_epoch: self.auth_epoch,
|
||||
password_upgraded,
|
||||
})
|
||||
}
|
||||
|
||||
/// Dump all users to the console.
|
||||
@@ -275,6 +434,7 @@ impl WebUsers {
|
||||
/// for demonstration purposes.
|
||||
pub fn allow_anonymous(&mut self, allow: bool) -> Result<(), AuthenticationError> {
|
||||
self.allow_unauthenticated_to_view = allow;
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -286,16 +446,6 @@ impl WebUsers {
|
||||
}
|
||||
|
||||
/// Errors that can occur while managing web-UI authentication.
|
||||
///
|
||||
/// This enum groups failures encountered by helpers in this module while
|
||||
/// interacting with `lqusers.toml` and related configuration, including:
|
||||
/// - Resolving the LibreQoS config directory via `crate::load_config`.
|
||||
/// - Serializing/deserializing user data with `toml_edit`.
|
||||
/// - Reading, writing, or deleting the `lqusers.toml` file on disk.
|
||||
/// - Looking up users, validating credentials, and resolving tokens.
|
||||
///
|
||||
/// Each variant implements a concise display message via `thiserror::Error`
|
||||
/// and is suitable for logging or bubbling up to higher layers.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthenticationError {
|
||||
/// Failed to load the main configuration (`/etc/lqos.conf`) to
|
||||
@@ -306,11 +456,8 @@ pub enum AuthenticationError {
|
||||
/// `lqusers.toml` to disk.
|
||||
#[error("Unable to serialize to TOML")]
|
||||
SerializationError(toml_edit::ser::Error),
|
||||
/// Failed to remove an existing `lqusers.toml` prior to rewriting it.
|
||||
#[error("Unable to remove existing web users file")]
|
||||
UnableToDelete,
|
||||
/// Failed to create or write `lqusers.toml` (e.g., permissions or IO).
|
||||
#[error("Unable to open lqusers.toml for writing. Check permissions?")]
|
||||
/// Failed to persist `lqusers.toml` (e.g., permissions or IO).
|
||||
#[error("Unable to write lqusers.toml")]
|
||||
UnableToWrite,
|
||||
/// Failed to read `lqusers.toml` from disk.
|
||||
#[error("Unable to read lqusers.toml")]
|
||||
@@ -318,13 +465,13 @@ pub enum AuthenticationError {
|
||||
/// Failed to parse `lqusers.toml` contents as valid TOML.
|
||||
#[error("Unable to parse lqusers.toml")]
|
||||
UnableToParse,
|
||||
/// Password hash creation or verification failed.
|
||||
#[error("Unable to process password hash")]
|
||||
PasswordHashError(argon2::password_hash::Error),
|
||||
/// Attempted to remove or reference a user that does not exist.
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
/// Username/password did not match (and anonymous read-only is disabled).
|
||||
/// Username/password did not match.
|
||||
#[error("Invalid Login")]
|
||||
InvalidLogin,
|
||||
/// Provided token did not match any user (and anonymous read-only is disabled).
|
||||
#[error("Invalid User Token")]
|
||||
InvalidToken,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Runtime metadata filename for circuit Ethernet advisories emitted by integrations.
|
||||
pub const CIRCUIT_ETHERNET_METADATA_FILENAME: &str = "circuit_ethernet_metadata.json";
|
||||
|
||||
/// Collection of circuit Ethernet advisories keyed by circuit identity.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CircuitEthernetMetadataFile {
|
||||
/// Per-circuit Ethernet advisory entries.
|
||||
pub circuits: Vec<CircuitEthernetMetadata>,
|
||||
}
|
||||
|
||||
/// Describes a detected negotiated Ethernet speed and any automatic shaping cap applied to a circuit.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CircuitEthernetMetadata {
|
||||
/// Circuit identifier as emitted to `ShapedDevices.csv`.
|
||||
pub circuit_id: String,
|
||||
/// Human-readable circuit name for UI display.
|
||||
pub circuit_name: String,
|
||||
/// Device IDs considered when determining the circuit Ethernet limit.
|
||||
pub device_ids: Vec<String>,
|
||||
/// Integration/source that produced the advisory.
|
||||
pub source: String,
|
||||
/// Negotiated Ethernet speed in Mbps for the limiting device/interface.
|
||||
pub negotiated_ethernet_mbps: u64,
|
||||
/// Requested download max before any Ethernet-based cap was applied.
|
||||
pub requested_download_mbps: f32,
|
||||
/// Requested upload max before any Ethernet-based cap was applied.
|
||||
pub requested_upload_mbps: f32,
|
||||
/// Applied download max after Ethernet-based capping.
|
||||
pub applied_download_mbps: f32,
|
||||
/// Applied upload max after Ethernet-based capping.
|
||||
pub applied_upload_mbps: f32,
|
||||
/// Whether the Ethernet advisory reduced at least one shaping direction.
|
||||
pub auto_capped: bool,
|
||||
/// Device ID of the limiting device when known.
|
||||
pub limiting_device_id: Option<String>,
|
||||
/// Device name of the limiting device when known.
|
||||
pub limiting_device_name: Option<String>,
|
||||
/// Interface name that reported the limiting Ethernet speed when known.
|
||||
pub limiting_interface_name: Option<String>,
|
||||
}
|
||||
@@ -432,6 +432,11 @@ fn detect_from_cpuid(possible: &[u32]) -> Option<ResolvedHybridCpuTopology> {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
use std::arch::x86_64::__cpuid_count;
|
||||
|
||||
// Query leaf 0 first to determine the maximum supported CPUID leaf before using leaf 0x1A.
|
||||
if __cpuid_count(0, 0).eax < 0x1a {
|
||||
return None;
|
||||
}
|
||||
|
||||
let original_affinity = sched_getaffinity(Pid::from_raw(0)).ok()?;
|
||||
let restore_affinity = original_affinity;
|
||||
let mut performance = Vec::new();
|
||||
@@ -448,7 +453,7 @@ fn detect_from_cpuid(possible: &[u32]) -> Option<ResolvedHybridCpuTopology> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let leaf = unsafe { __cpuid_count(0x1a, 0) };
|
||||
let leaf = __cpuid_count(0x1a, 0);
|
||||
let core_type = (leaf.eax >> 24) & 0xff;
|
||||
match core_type {
|
||||
CPUID_CORE_TYPE_CORE => performance.push(*cpu),
|
||||
|
||||
@@ -202,7 +202,13 @@ fn migrate_queues(
|
||||
new_config: &mut Config,
|
||||
) -> Result<(), MigrationError> {
|
||||
new_config.queues.default_sqm = python_config.sqm.clone();
|
||||
new_config.queues.monitor_only = python_config.monitor_only_mode;
|
||||
new_config
|
||||
.queues
|
||||
.set_queue_mode(if python_config.monitor_only_mode {
|
||||
super::QueueMode::Observe
|
||||
} else {
|
||||
super::QueueMode::Shape
|
||||
});
|
||||
new_config.queues.uplink_bandwidth_mbps =
|
||||
python_config.upstream_bandwidth_capacity_upload_mbps as u64;
|
||||
new_config.queues.downlink_bandwidth_mbps =
|
||||
|
||||
@@ -10,6 +10,7 @@ use once_cell::sync::Lazy;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use toml_edit::{DocumentMut, value};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
mod migration;
|
||||
@@ -18,12 +19,14 @@ mod python_migration;
|
||||
pub mod test_data;
|
||||
mod v15;
|
||||
pub use v15::{
|
||||
BridgeConfig, LazyQueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig,
|
||||
BridgeConfig, LazyQueueMode, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig,
|
||||
StormguardStrategy, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig,
|
||||
TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables,
|
||||
};
|
||||
|
||||
static CONFIG: Lazy<ArcSwap<Option<Arc<Config>>>> = Lazy::new(|| ArcSwap::from_pointee(None));
|
||||
static TREEGUARD_CPU_MODE_MIGRATION_NOTICE: Lazy<std::sync::Mutex<Option<String>>> =
|
||||
Lazy::new(|| std::sync::Mutex::new(None));
|
||||
|
||||
/// Load the configuration from `/etc/lqos.conf`.
|
||||
pub fn load_config() -> Result<Arc<Config>, LibreQoSConfigError> {
|
||||
@@ -39,6 +42,91 @@ pub fn load_config() -> Result<Arc<Config>, LibreQoSConfigError> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Clears the in-process cached configuration so the next `load_config()` reads from disk again.
|
||||
///
|
||||
/// This function has side effects: it mutates the process-global config cache.
|
||||
#[doc(hidden)]
|
||||
pub fn clear_cached_config() {
|
||||
CONFIG.store(None.into());
|
||||
}
|
||||
|
||||
/// Returns the current TreeGuard CPU-mode migration notice, if an automatic upgrade rewrite
|
||||
/// occurred during config load.
|
||||
pub fn treeguard_cpu_mode_migration_notice() -> Option<String> {
|
||||
TREEGUARD_CPU_MODE_MIGRATION_NOTICE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|notice| notice.clone())
|
||||
}
|
||||
|
||||
fn treeguard_cpu_mode_migration_stamp_path(config_path: &str) -> String {
|
||||
format!("{config_path}.treeguard_cpu_mode_migrated")
|
||||
}
|
||||
|
||||
fn maybe_migrate_treeguard_cpu_mode(
|
||||
config_location: &str,
|
||||
raw: String,
|
||||
) -> Result<String, LibreQoSConfigError> {
|
||||
let mut doc: DocumentMut = raw.parse().map_err(|e| LibreQoSConfigError::ParseError {
|
||||
path: config_location.to_string(),
|
||||
details: format!("Error parsing config: {e}"),
|
||||
})?;
|
||||
|
||||
let mode_item = doc
|
||||
.get("treeguard")
|
||||
.and_then(|section| section.as_table_like())
|
||||
.and_then(|treeguard| treeguard.get("cpu"))
|
||||
.and_then(|cpu| cpu.as_table_like())
|
||||
.and_then(|cpu| cpu.get("mode"))
|
||||
.and_then(|mode| mode.as_str());
|
||||
if mode_item != Some("traffic_rtt_only") {
|
||||
return Ok(raw);
|
||||
}
|
||||
|
||||
let stamp_path = treeguard_cpu_mode_migration_stamp_path(config_location);
|
||||
if Path::new(&stamp_path).exists() {
|
||||
return Ok(raw);
|
||||
}
|
||||
|
||||
let Some(cpu_table) = doc
|
||||
.get_mut("treeguard")
|
||||
.and_then(|section| section.as_table_like_mut())
|
||||
.and_then(|treeguard| treeguard.get_mut("cpu"))
|
||||
.and_then(|cpu| cpu.as_table_like_mut())
|
||||
else {
|
||||
return Ok(raw);
|
||||
};
|
||||
|
||||
cpu_table.insert("mode", value("cpu_aware"));
|
||||
let migrated = doc.to_string();
|
||||
|
||||
let config_path = Path::new(config_location);
|
||||
if config_path.exists() {
|
||||
let backup_path = format!("{config_location}.treeguard_cpu_mode_backup");
|
||||
std::fs::copy(config_path, &backup_path).map_err(|e| LibreQoSConfigError::CannotWrite {
|
||||
path: backup_path,
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
std::fs::write(config_path, &migrated).map_err(|e| LibreQoSConfigError::CannotWrite {
|
||||
path: config_location.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
std::fs::write(&stamp_path, b"cpu_aware\n").map_err(|e| LibreQoSConfigError::CannotWrite {
|
||||
path: stamp_path,
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let notice = "TreeGuard CPU mode was automatically migrated from traffic_rtt_only to cpu_aware during upgrade. CPU-aware mode is now the default and recommended virtualization policy.".to_string();
|
||||
info!("{notice}");
|
||||
if let Ok(mut slot) = TREEGUARD_CPU_MODE_MIGRATION_NOTICE.lock() {
|
||||
*slot = Some(notice);
|
||||
}
|
||||
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
fn actually_load_from_disk() -> Result<Arc<Config>, LibreQoSConfigError> {
|
||||
let config_location = if let Ok(lqos_config) = std::env::var("LQOS_CONFIG") {
|
||||
info!("Overriding lqos.conf location from environment variable.");
|
||||
@@ -79,6 +167,8 @@ fn actually_load_from_disk() -> Result<Arc<Config>, LibreQoSConfigError> {
|
||||
}
|
||||
})?;
|
||||
|
||||
let raw = maybe_migrate_treeguard_cpu_mode(&config_location, raw)?;
|
||||
|
||||
let mut final_config = Config::load_from_string(&raw).map_err(|e| {
|
||||
error!("Unable to parse {config_location}");
|
||||
LibreQoSConfigError::ParseError {
|
||||
|
||||
@@ -32,6 +32,8 @@ to_network = "eth1"
|
||||
|
||||
[queues]
|
||||
default_sqm = "cake diffserv4"
|
||||
queue_mode = "shape"
|
||||
# Legacy compatibility field preserved for older binaries during upgrades.
|
||||
monitor_only = false
|
||||
uplink_bandwidth_mbps = 1000
|
||||
downlink_bandwidth_mbps = 1000
|
||||
@@ -48,7 +50,7 @@ dry_run = false
|
||||
tick_seconds = 1
|
||||
|
||||
[treeguard.cpu]
|
||||
mode = "traffic_rtt_only"
|
||||
mode = "cpu_aware"
|
||||
cpu_high_pct = 75
|
||||
cpu_low_pct = 55
|
||||
|
||||
@@ -82,7 +84,7 @@ persist_sqm_overrides = true
|
||||
|
||||
[treeguard.qoo]
|
||||
enabled = true
|
||||
min_score = 80.0
|
||||
min_score = 70.0
|
||||
|
||||
[long_term_stats]
|
||||
gather_stats = true
|
||||
@@ -153,6 +155,8 @@ snmp_community = "public"
|
||||
airmax_model_ids = [ "" ]
|
||||
ltu_model_ids = [ "" ]
|
||||
active_status_ids = [ "" ]
|
||||
recurring_excluded_service_names = []
|
||||
recurring_service_rates = []
|
||||
|
||||
[influxdb]
|
||||
enable_influxdb = false
|
||||
|
||||
@@ -23,7 +23,7 @@ mod wispgate;
|
||||
|
||||
pub use bridge::*;
|
||||
pub use long_term_stats::LongTermStats;
|
||||
pub use queues::LazyQueueMode;
|
||||
pub use queues::{LazyQueueMode, QueueMode};
|
||||
pub use stormguard::{StormguardConfig, StormguardStrategy};
|
||||
pub use treeguard::{
|
||||
TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
//! Queue Generation definitions (originally from ispConfig.py)
|
||||
|
||||
use allocative::Allocative;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::ser::SerializeStruct;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Allocative)]
|
||||
/// Queue application mode.
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, Default, Allocative)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QueueMode {
|
||||
/// Apply LibreQoS queueing and shaping.
|
||||
#[default]
|
||||
Shape,
|
||||
/// Observe only; do not apply managed TC objects.
|
||||
Observe,
|
||||
}
|
||||
|
||||
impl QueueMode {
|
||||
/// Returns `true` when the queue mode is observe-only.
|
||||
pub const fn is_observe(self) -> bool {
|
||||
matches!(self, Self::Observe)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Allocative)]
|
||||
pub struct QueueConfig {
|
||||
/// Which SQM to use by default
|
||||
pub default_sqm: String,
|
||||
|
||||
/// Should we monitor only, and not shape traffic?
|
||||
pub monitor_only: bool,
|
||||
/// Queue application mode.
|
||||
pub queue_mode: QueueMode,
|
||||
|
||||
/// Upstream bandwidth total - download
|
||||
pub uplink_bandwidth_mbps: u64,
|
||||
@@ -49,6 +68,56 @@ pub struct QueueConfig {
|
||||
pub fast_queues_fq_codel: Option<f64>,
|
||||
}
|
||||
|
||||
impl Serialize for QueueConfig {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("QueueConfig", 15)?;
|
||||
state.serialize_field("default_sqm", &self.default_sqm)?;
|
||||
state.serialize_field("queue_mode", &self.queue_mode)?;
|
||||
// Preserve the legacy field during rewrites so older binaries that still
|
||||
// require it continue to parse upgraded configs successfully.
|
||||
state.serialize_field("monitor_only", &self.queue_mode.is_observe())?;
|
||||
state.serialize_field("uplink_bandwidth_mbps", &self.uplink_bandwidth_mbps)?;
|
||||
state.serialize_field("downlink_bandwidth_mbps", &self.downlink_bandwidth_mbps)?;
|
||||
state.serialize_field(
|
||||
"generated_pn_download_mbps",
|
||||
&self.generated_pn_download_mbps,
|
||||
)?;
|
||||
state.serialize_field("generated_pn_upload_mbps", &self.generated_pn_upload_mbps)?;
|
||||
state.serialize_field("dry_run", &self.dry_run)?;
|
||||
state.serialize_field("sudo", &self.sudo)?;
|
||||
state.serialize_field("override_available_queues", &self.override_available_queues)?;
|
||||
state.serialize_field("use_binpacking", &self.use_binpacking)?;
|
||||
state.serialize_field("lazy_queues", &self.lazy_queues)?;
|
||||
state.serialize_field("lazy_expire_seconds", &self.lazy_expire_seconds)?;
|
||||
state.serialize_field("lazy_threshold_bytes", &self.lazy_threshold_bytes)?;
|
||||
state.serialize_field("fast_queues_fq_codel", &self.fast_queues_fq_codel)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default)]
|
||||
struct QueueConfigCompat {
|
||||
default_sqm: String,
|
||||
queue_mode: Option<QueueMode>,
|
||||
monitor_only: bool,
|
||||
uplink_bandwidth_mbps: u64,
|
||||
downlink_bandwidth_mbps: u64,
|
||||
generated_pn_download_mbps: u64,
|
||||
generated_pn_upload_mbps: u64,
|
||||
dry_run: bool,
|
||||
sudo: bool,
|
||||
override_available_queues: Option<u32>,
|
||||
use_binpacking: bool,
|
||||
lazy_queues: Option<LazyQueueMode>,
|
||||
lazy_expire_seconds: Option<u64>,
|
||||
lazy_threshold_bytes: Option<u64>,
|
||||
fast_queues_fq_codel: Option<f64>,
|
||||
}
|
||||
|
||||
/// Lazy queue creation modes
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Default, Allocative)]
|
||||
pub enum LazyQueueMode {
|
||||
@@ -65,7 +134,7 @@ impl Default for QueueConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_sqm: "cake diffserv4".to_string(),
|
||||
monitor_only: false,
|
||||
queue_mode: QueueMode::Shape,
|
||||
uplink_bandwidth_mbps: 1_000,
|
||||
downlink_bandwidth_mbps: 1_000,
|
||||
generated_pn_download_mbps: 1_000,
|
||||
@@ -81,3 +150,121 @@ impl Default for QueueConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueConfig {
|
||||
/// Sets the queue application mode.
|
||||
pub fn set_queue_mode(&mut self, queue_mode: QueueMode) {
|
||||
self.queue_mode = queue_mode;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QueueConfigCompat {
|
||||
fn default() -> Self {
|
||||
let defaults = QueueConfig::default();
|
||||
Self {
|
||||
default_sqm: defaults.default_sqm.clone(),
|
||||
queue_mode: None,
|
||||
monitor_only: false,
|
||||
uplink_bandwidth_mbps: defaults.uplink_bandwidth_mbps,
|
||||
downlink_bandwidth_mbps: defaults.downlink_bandwidth_mbps,
|
||||
generated_pn_download_mbps: defaults.generated_pn_download_mbps,
|
||||
generated_pn_upload_mbps: defaults.generated_pn_upload_mbps,
|
||||
dry_run: defaults.dry_run,
|
||||
sudo: defaults.sudo,
|
||||
override_available_queues: defaults.override_available_queues,
|
||||
use_binpacking: defaults.use_binpacking,
|
||||
lazy_queues: defaults.lazy_queues.clone(),
|
||||
lazy_expire_seconds: defaults.lazy_expire_seconds,
|
||||
lazy_threshold_bytes: defaults.lazy_threshold_bytes,
|
||||
fast_queues_fq_codel: defaults.fast_queues_fq_codel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for QueueConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let compat = QueueConfigCompat::deserialize(deserializer)?;
|
||||
let queue_mode = compat.queue_mode.unwrap_or({
|
||||
if compat.monitor_only {
|
||||
QueueMode::Observe
|
||||
} else {
|
||||
QueueMode::Shape
|
||||
}
|
||||
});
|
||||
|
||||
let mut cfg = Self {
|
||||
default_sqm: compat.default_sqm,
|
||||
queue_mode,
|
||||
uplink_bandwidth_mbps: compat.uplink_bandwidth_mbps,
|
||||
downlink_bandwidth_mbps: compat.downlink_bandwidth_mbps,
|
||||
generated_pn_download_mbps: compat.generated_pn_download_mbps,
|
||||
generated_pn_upload_mbps: compat.generated_pn_upload_mbps,
|
||||
dry_run: compat.dry_run,
|
||||
sudo: compat.sudo,
|
||||
override_available_queues: compat.override_available_queues,
|
||||
use_binpacking: compat.use_binpacking,
|
||||
lazy_queues: compat.lazy_queues,
|
||||
lazy_expire_seconds: compat.lazy_expire_seconds,
|
||||
lazy_threshold_bytes: compat.lazy_threshold_bytes,
|
||||
fast_queues_fq_codel: compat.fast_queues_fq_codel,
|
||||
};
|
||||
cfg.set_queue_mode(queue_mode);
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{QueueConfig, QueueMode};
|
||||
|
||||
#[test]
|
||||
fn deserialize_legacy_monitor_only_maps_to_observe() {
|
||||
let parsed: QueueConfig =
|
||||
toml::from_str("default_sqm = \"cake diffserv4\"\nmonitor_only = true\n")
|
||||
.expect("legacy monitor_only config should deserialize");
|
||||
assert_eq!(parsed.queue_mode, QueueMode::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_without_queue_mode_defaults_to_shape() {
|
||||
let parsed: QueueConfig = toml::from_str("default_sqm = \"cake diffserv4\"\n")
|
||||
.expect("queue config without queue_mode should deserialize");
|
||||
assert_eq!(parsed.queue_mode, QueueMode::Shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_prefers_queue_mode_over_legacy_monitor_only() {
|
||||
let parsed: QueueConfig = toml::from_str(
|
||||
"default_sqm = \"cake diffserv4\"\nqueue_mode = \"shape\"\nmonitor_only = true\n",
|
||||
)
|
||||
.expect("mixed config should deserialize");
|
||||
assert_eq!(parsed.queue_mode, QueueMode::Shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_queue_config_preserves_legacy_monitor_only_field() {
|
||||
let mut config = QueueConfig::default();
|
||||
config.set_queue_mode(QueueMode::Observe);
|
||||
let serialized = toml::to_string(&config).expect("queue config should serialize");
|
||||
assert!(serialized.contains("queue_mode = \"observe\""));
|
||||
assert!(
|
||||
serialized.contains("monitor_only = true"),
|
||||
"serialized config should preserve legacy monitor_only for compatibility"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_shape_queue_config_sets_legacy_monitor_only_false() {
|
||||
let mut config = QueueConfig::default();
|
||||
config.set_queue_mode(QueueMode::Shape);
|
||||
let serialized = toml::to_string(&config).expect("queue config should serialize");
|
||||
assert!(serialized.contains("queue_mode = \"shape\""));
|
||||
assert!(
|
||||
serialized.contains("monitor_only = false"),
|
||||
"serialized config should preserve legacy monitor_only=false for compatibility"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
use allocative::Allocative;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative, Default)]
|
||||
pub struct SonarRecurringServiceRate {
|
||||
pub enabled: bool,
|
||||
pub service_name: String,
|
||||
pub download_mbps: f32,
|
||||
pub upload_mbps: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
|
||||
pub struct SonarIntegration {
|
||||
pub enable_sonar: bool,
|
||||
@@ -10,6 +18,10 @@ pub struct SonarIntegration {
|
||||
pub airmax_model_ids: Vec<String>,
|
||||
pub ltu_model_ids: Vec<String>,
|
||||
pub active_status_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub recurring_service_rates: Vec<SonarRecurringServiceRate>,
|
||||
#[serde(default)]
|
||||
pub recurring_excluded_service_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for SonarIntegration {
|
||||
@@ -22,6 +34,8 @@ impl Default for SonarIntegration {
|
||||
airmax_model_ids: vec![],
|
||||
ltu_model_ids: vec![],
|
||||
active_status_ids: vec![],
|
||||
recurring_service_rates: vec![],
|
||||
recurring_excluded_service_names: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,7 +511,7 @@ mod test {
|
||||
assert!(!cfg.treeguard.dry_run);
|
||||
assert_eq!(
|
||||
cfg.treeguard.cpu.mode,
|
||||
crate::etc::v15::treeguard::TreeguardCpuMode::TrafficRttOnly
|
||||
crate::etc::v15::treeguard::TreeguardCpuMode::CpuAware
|
||||
);
|
||||
assert!(cfg.treeguard.links.enabled);
|
||||
assert!(cfg.treeguard.links.all_nodes);
|
||||
|
||||
@@ -68,7 +68,7 @@ fn default_max_switches_per_hour() -> u32 {
|
||||
}
|
||||
|
||||
fn default_min_score() -> f32 {
|
||||
80.0
|
||||
70.0
|
||||
}
|
||||
|
||||
/// CPU modes supported by TreeGuard.
|
||||
@@ -76,9 +76,9 @@ fn default_min_score() -> f32 {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TreeguardCpuMode {
|
||||
/// Consider CPU thresholds when making decisions.
|
||||
#[default]
|
||||
CpuAware,
|
||||
/// Use only traffic and RTT signals.
|
||||
#[default]
|
||||
TrafficRttOnly,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,24 +8,38 @@
|
||||
#![deny(clippy::unwrap_used)]
|
||||
#![warn(missing_docs)]
|
||||
pub mod authentication;
|
||||
mod circuit_ethernet_metadata;
|
||||
mod cpu_topology;
|
||||
mod etc;
|
||||
mod network_json;
|
||||
mod planner;
|
||||
mod program_control;
|
||||
mod qoo_profiles;
|
||||
mod shaped_devices;
|
||||
|
||||
pub use authentication::{UserRole, WebUser, WebUsers};
|
||||
pub use authentication::{AuthenticatedUser, UserRole, WebUser, WebUsers};
|
||||
pub use circuit_ethernet_metadata::{
|
||||
CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, CircuitEthernetMetadataFile,
|
||||
};
|
||||
pub use cpu_topology::{
|
||||
CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus,
|
||||
};
|
||||
pub use etc::{
|
||||
BridgeConfig, Config, LazyQueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig,
|
||||
StormguardStrategy, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig,
|
||||
TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, disable_xdp_bridge,
|
||||
enable_long_term_stats, load_config, update_config,
|
||||
BridgeConfig, Config, LazyQueueMode, QueueMode, RttThresholds, SingleInterfaceConfig,
|
||||
StormguardConfig, StormguardStrategy, TreeguardCircuitsConfig, TreeguardConfig,
|
||||
TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables,
|
||||
clear_cached_config, disable_xdp_bridge, enable_long_term_stats, load_config,
|
||||
treeguard_cpu_mode_migration_notice, update_config,
|
||||
};
|
||||
pub use network_json::{NetworkJson, NetworkJsonNode, NetworkJsonTransport};
|
||||
pub use planner::{
|
||||
CircuitIdentityAssignment, CircuitIdentityGroupInput, ClassIdentityPlannerConstraints,
|
||||
ClassIdentityPlannerOutput, PlannerCircuitIdentityState, PlannerMinorReservations,
|
||||
PlannerSiteIdentityState, SiteIdentityAssignment, SiteIdentityInput, TopLevelPlannerItem,
|
||||
TopLevelPlannerMode, TopLevelPlannerOutput, TopLevelPlannerParams,
|
||||
build_class_identity_reservations, plan_class_identities,
|
||||
plan_class_identities_with_constraints, plan_top_level_assignments,
|
||||
};
|
||||
pub use program_control::load_libreqos;
|
||||
pub use qoo_profiles::{
|
||||
DEFAULT_QOO_PROFILE_ID, QooProfileInfo, QooProfilesError, active_qoo_profile,
|
||||
|
||||
@@ -87,6 +87,7 @@ impl NetworkJson {
|
||||
current_udp_packets: DownUpOrder::zeroed(),
|
||||
current_icmp_packets: DownUpOrder::zeroed(),
|
||||
current_tcp_retransmits: DownUpOrder::zeroed(),
|
||||
current_tcp_retransmit_packets: DownUpOrder::zeroed(),
|
||||
current_drops: DownUpOrder::zeroed(),
|
||||
current_marks: DownUpOrder::zeroed(),
|
||||
parents: Vec::new(),
|
||||
@@ -169,6 +170,7 @@ impl NetworkJson {
|
||||
n.current_udp_packets.set_to_zero();
|
||||
n.current_icmp_packets.set_to_zero();
|
||||
n.current_tcp_retransmits.set_to_zero();
|
||||
n.current_tcp_retransmit_packets.set_to_zero();
|
||||
n.rtt_buffer.clear();
|
||||
n.current_drops.set_to_zero();
|
||||
n.current_marks.set_to_zero();
|
||||
@@ -214,11 +216,17 @@ impl NetworkJson {
|
||||
}
|
||||
|
||||
/// Record TCP Retransmits in the tree.
|
||||
pub fn add_retransmit_cycle(&mut self, targets: &[usize], tcp_retransmits: DownUpOrder<u64>) {
|
||||
pub fn add_retransmit_cycle(
|
||||
&mut self,
|
||||
targets: &[usize],
|
||||
tcp_retransmits: DownUpOrder<u64>,
|
||||
tcp_packets: DownUpOrder<u64>,
|
||||
) {
|
||||
for idx in targets {
|
||||
// Safety first; use "get" to ensure that the node exists
|
||||
if let Some(node) = self.nodes.get_mut(*idx) {
|
||||
node.current_tcp_retransmits.checked_add(tcp_retransmits);
|
||||
node.current_tcp_retransmit_packets.checked_add(tcp_packets);
|
||||
} else {
|
||||
warn!("No network tree entry for index {idx}");
|
||||
}
|
||||
@@ -280,10 +288,12 @@ impl NetworkJson {
|
||||
.map(|rtt| rtt.as_millis() as f32);
|
||||
let retransmit_down = retransmit_percent(
|
||||
node.current_tcp_retransmits.down,
|
||||
node.current_tcp_packets.down,
|
||||
node.current_tcp_retransmit_packets.down,
|
||||
);
|
||||
let retransmit_up = retransmit_percent(
|
||||
node.current_tcp_retransmits.up,
|
||||
node.current_tcp_retransmit_packets.up,
|
||||
);
|
||||
let retransmit_up =
|
||||
retransmit_percent(node.current_tcp_retransmits.up, node.current_tcp_packets.up);
|
||||
|
||||
let heatmap = node.heatmap.get_or_insert_with(TemporalHeatmap::new);
|
||||
heatmap.add_sample(
|
||||
@@ -299,11 +309,11 @@ impl NetworkJson {
|
||||
|
||||
let loss_download = tcp_retransmit_loss_proxy(
|
||||
node.current_tcp_retransmits.down,
|
||||
node.current_tcp_packets.down,
|
||||
node.current_tcp_retransmit_packets.down,
|
||||
);
|
||||
let loss_upload = tcp_retransmit_loss_proxy(
|
||||
node.current_tcp_retransmits.up,
|
||||
node.current_tcp_packets.up,
|
||||
node.current_tcp_retransmit_packets.up,
|
||||
);
|
||||
let scores = if let Some(profile) = qoo_profile.as_ref() {
|
||||
compute_qoq_scores(
|
||||
@@ -380,6 +390,7 @@ fn recurse_node(
|
||||
current_udp_packets: DownUpOrder::zeroed(),
|
||||
current_icmp_packets: DownUpOrder::zeroed(),
|
||||
current_tcp_retransmits: DownUpOrder::zeroed(),
|
||||
current_tcp_retransmit_packets: DownUpOrder::zeroed(),
|
||||
current_drops: DownUpOrder::zeroed(),
|
||||
current_marks: DownUpOrder::zeroed(),
|
||||
name: name.to_string(),
|
||||
@@ -467,6 +478,7 @@ mod test {
|
||||
current_udp_packets: DownUpOrder::zeroed(),
|
||||
current_icmp_packets: DownUpOrder::zeroed(),
|
||||
current_tcp_retransmits: DownUpOrder::zeroed(),
|
||||
current_tcp_retransmit_packets: DownUpOrder::zeroed(),
|
||||
current_drops: DownUpOrder::zeroed(),
|
||||
current_marks: DownUpOrder::zeroed(),
|
||||
parents: Vec::new(),
|
||||
@@ -570,8 +582,10 @@ mod test {
|
||||
assert!(tiny.max_throughput.1 > 0.0);
|
||||
|
||||
let transport = tiny.clone_to_transit();
|
||||
assert!(!transport.runtime_virtualized);
|
||||
let encoded = serde_json::to_value(&transport).expect("transport must serialize");
|
||||
assert_eq!(encoded["max_throughput"], serde_json::json!([1.5, 0.5]));
|
||||
assert_eq!(encoded["runtime_virtualized"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -606,14 +620,8 @@ mod test {
|
||||
|
||||
assert_eq!(site.id.as_deref(), Some("uisp:site:123"));
|
||||
assert_eq!(ap.id.as_deref(), Some("uisp:device:456"));
|
||||
assert_eq!(
|
||||
site.clone_to_transit().id.as_deref(),
|
||||
Some("uisp:site:123")
|
||||
);
|
||||
assert_eq!(
|
||||
ap.clone_to_transit().id.as_deref(),
|
||||
Some("uisp:device:456")
|
||||
);
|
||||
assert_eq!(site.clone_to_transit().id.as_deref(), Some("uisp:site:123"));
|
||||
assert_eq!(ap.clone_to_transit().id.as_deref(), Some("uisp:device:456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -647,14 +655,20 @@ mod test {
|
||||
.find(|n| n.name == "Bad Tower")
|
||||
.expect("Bad Tower must be present");
|
||||
|
||||
assert!((good.latitude.unwrap() - 45.123).abs() < 0.001);
|
||||
assert!((good.longitude.unwrap() + 111.75).abs() < 0.001);
|
||||
let good_latitude = good.latitude.expect("Tower A latitude must parse");
|
||||
let good_longitude = good.longitude.expect("Tower A longitude must parse");
|
||||
assert!((good_latitude - 45.123).abs() < 0.001);
|
||||
assert!((good_longitude + 111.75).abs() < 0.001);
|
||||
assert_eq!(bad.latitude, None);
|
||||
assert_eq!(bad.longitude, None);
|
||||
|
||||
let encoded = serde_json::to_value(good.clone_to_transit()).expect("transport serializes");
|
||||
let encoded_lat = encoded["latitude"].as_f64().expect("latitude encodes as number");
|
||||
let encoded_lon = encoded["longitude"].as_f64().expect("longitude encodes as number");
|
||||
let encoded_lat = encoded["latitude"]
|
||||
.as_f64()
|
||||
.expect("latitude encodes as number");
|
||||
let encoded_lon = encoded["longitude"]
|
||||
.as_f64()
|
||||
.expect("longitude encodes as number");
|
||||
assert!((encoded_lat - 45.123).abs() < 0.001);
|
||||
assert!((encoded_lon + 111.75).abs() < 0.001);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct NetworkJsonNode {
|
||||
/// Current TCP Retransmits
|
||||
pub current_tcp_retransmits: DownUpOrder<u64>, // In retries
|
||||
|
||||
/// TCP packets paired to the retransmit counters for the current cycle.
|
||||
pub current_tcp_retransmit_packets: DownUpOrder<u64>,
|
||||
|
||||
/// Current Cake Marks
|
||||
pub current_marks: DownUpOrder<u64>,
|
||||
|
||||
@@ -107,6 +110,7 @@ impl NetworkJsonNode {
|
||||
name: self.name.clone(),
|
||||
id: self.id.clone(),
|
||||
is_virtual: self.virtual_node,
|
||||
runtime_virtualized: false,
|
||||
max_throughput: self.max_throughput,
|
||||
configured_max_throughput: self.max_throughput,
|
||||
effective_max_throughput: None,
|
||||
@@ -134,6 +138,10 @@ impl NetworkJsonNode {
|
||||
self.current_tcp_retransmits.get_down(),
|
||||
self.current_tcp_retransmits.get_up(),
|
||||
),
|
||||
current_tcp_retransmit_packets: (
|
||||
self.current_tcp_retransmit_packets.get_down(),
|
||||
self.current_tcp_retransmit_packets.get_up(),
|
||||
),
|
||||
current_marks: (self.current_marks.get_down(), self.current_marks.get_up()),
|
||||
current_drops: (self.current_drops.get_down(), self.current_drops.get_up()),
|
||||
rtts,
|
||||
@@ -143,6 +151,9 @@ impl NetworkJsonNode {
|
||||
node_type: self.node_type.clone(),
|
||||
latitude: self.latitude,
|
||||
longitude: self.longitude,
|
||||
subtree_site_count: 0,
|
||||
subtree_circuit_count: 0,
|
||||
subtree_device_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ pub struct NetworkJsonTransport {
|
||||
/// True if this node is a "virtual" (logical-only) node.
|
||||
#[serde(rename = "virtual", default)]
|
||||
pub is_virtual: bool,
|
||||
/// True if TreeGuard has runtime-virtualized this node in the active Bakery tree.
|
||||
#[serde(default)]
|
||||
pub runtime_virtualized: bool,
|
||||
/// Max throughput for node (not clamped)
|
||||
pub max_throughput: (f64, f64),
|
||||
/// Configured max throughput from `network.json`.
|
||||
@@ -34,6 +37,9 @@ pub struct NetworkJsonTransport {
|
||||
pub current_icmp_packets: (u64, u64),
|
||||
/// Current count of TCP retransmits
|
||||
pub current_retransmits: (u64, u64),
|
||||
/// TCP packets paired with current retransmit counts.
|
||||
#[serde(default)]
|
||||
pub current_tcp_retransmit_packets: (u64, u64),
|
||||
/// Cake marks
|
||||
pub current_marks: (u64, u64),
|
||||
/// Cake drops
|
||||
@@ -58,4 +64,16 @@ pub struct NetworkJsonTransport {
|
||||
/// Optional node longitude from network.json metadata.
|
||||
#[serde(default)]
|
||||
pub longitude: Option<f32>,
|
||||
/// Total number of descendant site-tree nodes below this node.
|
||||
///
|
||||
/// This excludes the node itself. For the synthetic root node, this is the
|
||||
/// total number of nodes in the loaded site tree.
|
||||
#[serde(default)]
|
||||
pub subtree_site_count: u32,
|
||||
/// Total number of unique circuits attached to this node or any descendant node.
|
||||
#[serde(default)]
|
||||
pub subtree_circuit_count: u32,
|
||||
/// Total number of shaped devices attached to this node or any descendant node.
|
||||
#[serde(default)]
|
||||
pub subtree_device_count: u32,
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
//! Shared planning helpers for physical queue placement.
|
||||
//!
|
||||
//! This module provides a reusable top-level CPU/bin planner so both the Python shaper build path
|
||||
//! and future Bakery/TreeGuard runtime replans can use the same assignment logic.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// A top-level item to be assigned to a shaping queue/bin.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TopLevelPlannerItem {
|
||||
/// Stable logical identifier for the item being planned.
|
||||
pub id: String,
|
||||
/// Relative weight used when balancing items across bins.
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
/// Supported planner strategies.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum TopLevelPlannerMode {
|
||||
/// Deterministic round-robin assignment by item id.
|
||||
RoundRobin,
|
||||
/// Deterministic greedy balancing with no hysteresis or cooldown.
|
||||
Greedy,
|
||||
/// Greedy balancing that prefers to keep prior assignments unless a move is worthwhile.
|
||||
#[default]
|
||||
StableGreedy,
|
||||
}
|
||||
|
||||
/// Tunables for top-level queue planning.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TopLevelPlannerParams {
|
||||
/// Planning mode.
|
||||
pub mode: TopLevelPlannerMode,
|
||||
/// Fraction of total weight improvement required before a move is considered worthwhile.
|
||||
pub hysteresis_threshold: f64,
|
||||
/// Minimum seconds between moves for a previously assigned item.
|
||||
pub cooldown_seconds: f64,
|
||||
/// Maximum number of assignment changes allowed in a single planning run.
|
||||
pub move_budget_per_run: usize,
|
||||
}
|
||||
|
||||
impl Default for TopLevelPlannerParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: TopLevelPlannerMode::StableGreedy,
|
||||
hysteresis_threshold: 0.03,
|
||||
cooldown_seconds: 3600.0,
|
||||
move_budget_per_run: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a top-level assignment pass.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TopLevelPlannerOutput {
|
||||
/// Final per-item bin assignment.
|
||||
pub assignment: BTreeMap<String, String>,
|
||||
/// Item ids whose assigned bin changed relative to the previous assignment map.
|
||||
pub changed: Vec<String>,
|
||||
/// Whether the stable planner path, rather than a simple fallback, was used.
|
||||
pub planner_used: bool,
|
||||
}
|
||||
|
||||
/// Input describing a site that needs class identity assignment.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SiteIdentityInput {
|
||||
/// Stable path key for the site within the logical hierarchy.
|
||||
pub site_key: String,
|
||||
/// Stable path key for the parent site, or empty for top-level nodes.
|
||||
pub parent_path: String,
|
||||
/// Target shaping queue number, 1-based.
|
||||
pub queue: u32,
|
||||
/// Whether this site has child sites in the physical tree.
|
||||
pub has_children: bool,
|
||||
}
|
||||
|
||||
/// Assigned class identity for a site.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SiteIdentityAssignment {
|
||||
/// Stable path key for the site.
|
||||
pub site_key: String,
|
||||
/// Assigned queue number, 1-based.
|
||||
pub queue: u32,
|
||||
/// Assigned class minor.
|
||||
pub class_minor: u16,
|
||||
/// Assigned downlink class major.
|
||||
pub class_major: u16,
|
||||
/// Assigned uplink class major.
|
||||
pub up_class_major: u16,
|
||||
/// Parent path used when deciding minor reuse.
|
||||
pub parent_path: String,
|
||||
}
|
||||
|
||||
/// Input describing a circuit group under a single parent site.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CircuitIdentityGroupInput {
|
||||
/// Parent node name for this group of circuits.
|
||||
pub parent_node: String,
|
||||
/// Queue number, 1-based.
|
||||
pub queue: u32,
|
||||
/// Circuit ids in deterministic order for this parent.
|
||||
pub circuit_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Assigned class identity for a circuit.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CircuitIdentityAssignment {
|
||||
/// Stable circuit id.
|
||||
pub circuit_id: String,
|
||||
/// Parent node used when deciding minor reuse.
|
||||
pub parent_node: String,
|
||||
/// Assigned queue number, 1-based.
|
||||
pub queue: u32,
|
||||
/// Assigned class minor.
|
||||
pub class_minor: u16,
|
||||
/// Assigned downlink class major.
|
||||
pub class_major: u16,
|
||||
/// Assigned uplink class major.
|
||||
pub up_class_major: u16,
|
||||
}
|
||||
|
||||
/// Persisted planner identity for a site.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PlannerSiteIdentityState {
|
||||
/// Assigned class minor.
|
||||
pub class_minor: u16,
|
||||
/// Assigned queue number, 1-based.
|
||||
pub queue: u32,
|
||||
/// Stable parent path for reuse checks.
|
||||
pub parent_path: String,
|
||||
/// Downlink class major.
|
||||
pub class_major: u16,
|
||||
/// Uplink class major.
|
||||
pub up_class_major: u16,
|
||||
}
|
||||
|
||||
/// Persisted planner identity for a circuit.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PlannerCircuitIdentityState {
|
||||
/// Assigned class minor.
|
||||
pub class_minor: u16,
|
||||
/// Assigned queue number, 1-based.
|
||||
pub queue: u32,
|
||||
/// Stable parent node for reuse checks.
|
||||
pub parent_node: String,
|
||||
/// Downlink class major.
|
||||
pub class_major: u16,
|
||||
/// Uplink class major.
|
||||
pub up_class_major: u16,
|
||||
}
|
||||
|
||||
/// Output of site/circuit identity assignment.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ClassIdentityPlannerOutput {
|
||||
/// Assigned site identities keyed in traversal order.
|
||||
pub sites: Vec<SiteIdentityAssignment>,
|
||||
/// Assigned circuit identities keyed in traversal order.
|
||||
pub circuits: Vec<CircuitIdentityAssignment>,
|
||||
/// Planner-state site map suitable for persistence.
|
||||
pub site_state: BTreeMap<String, PlannerSiteIdentityState>,
|
||||
/// Planner-state circuit map suitable for persistence.
|
||||
pub circuit_state: BTreeMap<String, PlannerCircuitIdentityState>,
|
||||
/// Highest allocated minor counter per queue after assignment.
|
||||
pub last_used_minor_by_queue: BTreeMap<u32, u32>,
|
||||
}
|
||||
|
||||
/// Reserved minor numbers keyed by shaping queue.
|
||||
pub type PlannerMinorReservations = BTreeMap<u32, BTreeSet<u32>>;
|
||||
|
||||
/// Structural constraints and numbering offsets for class identity planning.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ClassIdentityPlannerConstraints {
|
||||
/// Reserved site minors keyed by queue.
|
||||
pub reserved_site_minors: PlannerMinorReservations,
|
||||
/// Reserved circuit minors keyed by queue.
|
||||
pub reserved_circuit_minors: PlannerMinorReservations,
|
||||
/// Starting minor for planned site classes.
|
||||
pub site_minor_start: u32,
|
||||
/// Starting minor for planned circuit classes.
|
||||
pub circuit_minor_start: u32,
|
||||
/// Uplink major offset for on-a-stick layouts.
|
||||
pub stick_offset: u16,
|
||||
/// Padding to leave after each circuit group allocation.
|
||||
pub circuit_padding: u32,
|
||||
}
|
||||
|
||||
fn sanitize_weight(weight: f64) -> f64 {
|
||||
if !weight.is_finite() || weight <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
weight
|
||||
}
|
||||
}
|
||||
|
||||
fn next_free_minor(start_minor: u32, reserved: &BTreeSet<u32>) -> u32 {
|
||||
let mut candidate = start_minor.max(3);
|
||||
while reserved.contains(&candidate) {
|
||||
candidate += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Builds reservation maps for identities that are not part of the current planning scope.
|
||||
pub fn build_class_identity_reservations(
|
||||
sites: &[SiteIdentityInput],
|
||||
circuit_groups: &[CircuitIdentityGroupInput],
|
||||
previous_sites: &BTreeMap<String, PlannerSiteIdentityState>,
|
||||
previous_circuits: &BTreeMap<String, PlannerCircuitIdentityState>,
|
||||
) -> (PlannerMinorReservations, PlannerMinorReservations) {
|
||||
let planned_site_keys: BTreeSet<&str> =
|
||||
sites.iter().map(|site| site.site_key.as_str()).collect();
|
||||
let planned_circuit_ids: BTreeSet<&str> = circuit_groups
|
||||
.iter()
|
||||
.flat_map(|group| group.circuit_ids.iter().map(|id| id.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut reserved_site_minors: PlannerMinorReservations = BTreeMap::new();
|
||||
for (site_key, stored) in previous_sites {
|
||||
if planned_site_keys.contains(site_key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
reserved_site_minors
|
||||
.entry(stored.queue)
|
||||
.or_default()
|
||||
.insert(stored.class_minor as u32);
|
||||
}
|
||||
|
||||
let mut reserved_circuit_minors: PlannerMinorReservations = BTreeMap::new();
|
||||
for (circuit_id, stored) in previous_circuits {
|
||||
if planned_circuit_ids.contains(circuit_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
reserved_circuit_minors
|
||||
.entry(stored.queue)
|
||||
.or_default()
|
||||
.insert(stored.class_minor as u32);
|
||||
}
|
||||
|
||||
(reserved_site_minors, reserved_circuit_minors)
|
||||
}
|
||||
|
||||
fn round_robin_assign(items: &[TopLevelPlannerItem], bins: &[String]) -> BTreeMap<String, String> {
|
||||
let mut ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect();
|
||||
ids.sort_unstable();
|
||||
|
||||
let mut assignment = BTreeMap::new();
|
||||
for (idx, id) in ids.into_iter().enumerate() {
|
||||
if let Some(bin) = bins.get(idx % bins.len()) {
|
||||
assignment.insert(id.to_string(), bin.clone());
|
||||
}
|
||||
}
|
||||
assignment
|
||||
}
|
||||
|
||||
fn greedy_assign(items: &[TopLevelPlannerItem], bins: &[String]) -> BTreeMap<String, String> {
|
||||
let mut pairs: Vec<(&str, f64)> = items
|
||||
.iter()
|
||||
.map(|item| (item.id.as_str(), sanitize_weight(item.weight)))
|
||||
.collect();
|
||||
pairs.sort_by(|a, b| {
|
||||
b.1.partial_cmp(&a.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.0.cmp(b.0))
|
||||
});
|
||||
|
||||
let mut loads: BTreeMap<String, f64> = bins.iter().cloned().map(|bin| (bin, 0.0)).collect();
|
||||
let mut assignment = BTreeMap::new();
|
||||
for (id, weight) in pairs {
|
||||
let target = loads
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
a.1.partial_cmp(b.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.0.cmp(b.0))
|
||||
})
|
||||
.map(|(bin, _)| bin.clone())
|
||||
.unwrap_or_else(|| bins[0].clone());
|
||||
assignment.insert(id.to_string(), target.clone());
|
||||
if let Some(load) = loads.get_mut(&target) {
|
||||
*load += weight;
|
||||
}
|
||||
}
|
||||
assignment
|
||||
}
|
||||
|
||||
fn load_by_bin(
|
||||
assignment: &BTreeMap<String, String>,
|
||||
item_weights: &BTreeMap<String, f64>,
|
||||
bins: &[String],
|
||||
) -> BTreeMap<String, f64> {
|
||||
let mut loads: BTreeMap<String, f64> = bins.iter().cloned().map(|bin| (bin, 0.0)).collect();
|
||||
for (id, bin) in assignment {
|
||||
if let Some(weight) = item_weights.get(id)
|
||||
&& let Some(load) = loads.get_mut(bin)
|
||||
{
|
||||
*load += *weight;
|
||||
}
|
||||
}
|
||||
loads
|
||||
}
|
||||
|
||||
/// Plans top-level queue assignments for the given logical items and bins.
|
||||
///
|
||||
/// This function is pure: it computes a deterministic assignment and does not perform any I/O.
|
||||
pub fn plan_top_level_assignments(
|
||||
items: &[TopLevelPlannerItem],
|
||||
bins: &[String],
|
||||
prev_assign: &BTreeMap<String, String>,
|
||||
last_change_ts: &BTreeMap<String, f64>,
|
||||
now_ts: f64,
|
||||
params: &TopLevelPlannerParams,
|
||||
) -> TopLevelPlannerOutput {
|
||||
if bins.is_empty() || items.is_empty() {
|
||||
return TopLevelPlannerOutput {
|
||||
assignment: BTreeMap::new(),
|
||||
changed: Vec::new(),
|
||||
planner_used: !matches!(params.mode, TopLevelPlannerMode::RoundRobin),
|
||||
};
|
||||
}
|
||||
|
||||
let item_ids: Vec<String> = items.iter().map(|item| item.id.clone()).collect();
|
||||
let item_weights: BTreeMap<String, f64> = items
|
||||
.iter()
|
||||
.map(|item| (item.id.clone(), sanitize_weight(item.weight)))
|
||||
.collect();
|
||||
let valid_bins: std::collections::BTreeSet<&String> = bins.iter().collect();
|
||||
let prev_valid: BTreeMap<String, String> = prev_assign
|
||||
.iter()
|
||||
.filter(|(id, bin)| item_weights.contains_key(*id) && valid_bins.contains(*bin))
|
||||
.map(|(id, bin)| (id.clone(), bin.clone()))
|
||||
.collect();
|
||||
|
||||
let rr_assignment = round_robin_assign(items, bins);
|
||||
let greedy_assignment = greedy_assign(items, bins);
|
||||
|
||||
let (mut assignment, planner_used) = match params.mode {
|
||||
TopLevelPlannerMode::RoundRobin => (rr_assignment.clone(), false),
|
||||
TopLevelPlannerMode::Greedy => (greedy_assignment.clone(), false),
|
||||
TopLevelPlannerMode::StableGreedy => {
|
||||
let mut assignment = rr_assignment.clone();
|
||||
for id in &item_ids {
|
||||
let chosen = prev_valid
|
||||
.get(id)
|
||||
.cloned()
|
||||
.or_else(|| greedy_assignment.get(id).cloned())
|
||||
.or_else(|| rr_assignment.get(id).cloned())
|
||||
.unwrap_or_else(|| bins[0].clone());
|
||||
assignment.insert(id.clone(), chosen);
|
||||
}
|
||||
|
||||
let mut loads = load_by_bin(&assignment, &item_weights, bins);
|
||||
let total_weight: f64 = item_weights.values().sum();
|
||||
let min_improvement = total_weight * params.hysteresis_threshold.max(0.0);
|
||||
let mut weighted_ids = item_ids.clone();
|
||||
weighted_ids.sort_by(|a, b| {
|
||||
item_weights[b]
|
||||
.partial_cmp(&item_weights[a])
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.cmp(b))
|
||||
});
|
||||
|
||||
let mut moves_used = 0usize;
|
||||
for id in weighted_ids {
|
||||
let Some(current_bin) = assignment.get(&id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let Some(target_bin) = greedy_assignment.get(&id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
if current_bin == target_bin {
|
||||
continue;
|
||||
}
|
||||
if moves_used >= params.move_budget_per_run {
|
||||
continue;
|
||||
}
|
||||
let last_changed = last_change_ts.get(&id).copied().unwrap_or(0.0);
|
||||
if now_ts - last_changed < params.cooldown_seconds {
|
||||
continue;
|
||||
}
|
||||
let current_load = loads.get(¤t_bin).copied().unwrap_or(0.0);
|
||||
let target_load = loads.get(&target_bin).copied().unwrap_or(0.0);
|
||||
if current_load - target_load <= min_improvement {
|
||||
continue;
|
||||
}
|
||||
|
||||
let weight = item_weights.get(&id).copied().unwrap_or(1.0);
|
||||
if let Some(load) = loads.get_mut(¤t_bin) {
|
||||
*load -= weight;
|
||||
}
|
||||
if let Some(load) = loads.get_mut(&target_bin) {
|
||||
*load += weight;
|
||||
}
|
||||
assignment.insert(id, target_bin);
|
||||
moves_used += 1;
|
||||
}
|
||||
|
||||
(assignment, true)
|
||||
}
|
||||
};
|
||||
|
||||
let used_bins: std::collections::BTreeSet<&String> = assignment.values().collect();
|
||||
if bins.len() > 1 && assignment.len() > 1 && used_bins.len() <= 1 {
|
||||
assignment = greedy_assignment.clone();
|
||||
}
|
||||
|
||||
let mut changed: Vec<String> = assignment
|
||||
.iter()
|
||||
.filter_map(|(id, bin)| {
|
||||
prev_valid
|
||||
.get(id)
|
||||
.and_then(|prev| (prev != bin).then_some(id.clone()))
|
||||
})
|
||||
.collect();
|
||||
changed.sort_unstable();
|
||||
|
||||
TopLevelPlannerOutput {
|
||||
assignment,
|
||||
changed,
|
||||
planner_used,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plans site and circuit class identities for a physical queue tree.
|
||||
///
|
||||
/// This function is pure: it computes deterministic minor/class-major assignments from the
|
||||
/// provided planner state and traversal order without performing any I/O.
|
||||
pub fn plan_class_identities_with_constraints(
|
||||
sites: &[SiteIdentityInput],
|
||||
circuit_groups: &[CircuitIdentityGroupInput],
|
||||
previous_sites: &BTreeMap<String, PlannerSiteIdentityState>,
|
||||
previous_circuits: &BTreeMap<String, PlannerCircuitIdentityState>,
|
||||
constraints: &ClassIdentityPlannerConstraints,
|
||||
) -> ClassIdentityPlannerOutput {
|
||||
let mut reserved_site_minors = constraints.reserved_site_minors.clone();
|
||||
let mut next_site_minor_by_queue: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
let mut site_assignments = Vec::with_capacity(sites.len());
|
||||
let mut site_state = BTreeMap::new();
|
||||
for (queue, reserved) in &reserved_site_minors {
|
||||
let next_minor = next_site_minor_by_queue
|
||||
.entry(*queue)
|
||||
.or_insert(constraints.site_minor_start.max(3));
|
||||
if let Some(last_reserved) = reserved.iter().next_back().copied() {
|
||||
*next_minor = (*next_minor).max(last_reserved.saturating_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
for site in sites {
|
||||
let reserved = reserved_site_minors.entry(site.queue).or_default();
|
||||
let next_minor = next_site_minor_by_queue
|
||||
.entry(site.queue)
|
||||
.or_insert(constraints.site_minor_start.max(3));
|
||||
let reuse_minor = previous_sites.get(&site.site_key).and_then(|stored| {
|
||||
(stored.queue == site.queue
|
||||
&& stored.parent_path == site.parent_path
|
||||
&& !reserved.contains(&(stored.class_minor as u32)))
|
||||
.then_some(stored.class_minor as u32)
|
||||
});
|
||||
|
||||
let class_minor_u32 = reuse_minor.unwrap_or_else(|| next_free_minor(*next_minor, reserved));
|
||||
let class_minor = u16::try_from(class_minor_u32).unwrap_or(u16::MAX);
|
||||
let class_major = u16::try_from(site.queue).unwrap_or(u16::MAX);
|
||||
let up_class_major = class_major.saturating_add(constraints.stick_offset);
|
||||
reserved.insert(class_minor_u32);
|
||||
*next_minor = (*next_minor).max(class_minor_u32).saturating_add(1);
|
||||
if site.has_children {
|
||||
*next_minor = (*next_minor).saturating_add(1);
|
||||
}
|
||||
|
||||
let assigned = SiteIdentityAssignment {
|
||||
site_key: site.site_key.clone(),
|
||||
queue: site.queue,
|
||||
class_minor,
|
||||
class_major,
|
||||
up_class_major,
|
||||
parent_path: site.parent_path.clone(),
|
||||
};
|
||||
site_state.insert(
|
||||
site.site_key.clone(),
|
||||
PlannerSiteIdentityState {
|
||||
class_minor,
|
||||
queue: site.queue,
|
||||
parent_path: site.parent_path.clone(),
|
||||
class_major,
|
||||
up_class_major,
|
||||
},
|
||||
);
|
||||
site_assignments.push(assigned);
|
||||
}
|
||||
|
||||
let mut reserved_circuit_minors = reserved_site_minors.clone();
|
||||
for (queue, extras) in &constraints.reserved_circuit_minors {
|
||||
reserved_circuit_minors
|
||||
.entry(*queue)
|
||||
.or_default()
|
||||
.extend(extras.iter().copied());
|
||||
}
|
||||
let mut next_circuit_minor_by_queue = BTreeMap::new();
|
||||
for (queue, reserved) in &reserved_circuit_minors {
|
||||
let start = next_site_minor_by_queue
|
||||
.get(queue)
|
||||
.copied()
|
||||
.unwrap_or_else(|| next_free_minor(constraints.circuit_minor_start.max(3), reserved));
|
||||
next_circuit_minor_by_queue.insert(
|
||||
*queue,
|
||||
next_free_minor(start.max(constraints.circuit_minor_start.max(3)), reserved),
|
||||
);
|
||||
}
|
||||
|
||||
let mut circuit_assignments = Vec::new();
|
||||
let mut circuit_state = BTreeMap::new();
|
||||
for group in circuit_groups {
|
||||
let reserved = reserved_circuit_minors.entry(group.queue).or_default();
|
||||
let next_minor = next_circuit_minor_by_queue
|
||||
.entry(group.queue)
|
||||
.or_insert_with(|| next_free_minor(constraints.circuit_minor_start.max(3), reserved));
|
||||
for circuit_id in &group.circuit_ids {
|
||||
let reuse_minor = previous_circuits.get(circuit_id).and_then(|stored| {
|
||||
(stored.queue == group.queue
|
||||
&& stored.parent_node == group.parent_node
|
||||
&& !reserved.contains(&(stored.class_minor as u32)))
|
||||
.then_some(stored.class_minor as u32)
|
||||
});
|
||||
let class_minor_u32 =
|
||||
reuse_minor.unwrap_or_else(|| next_free_minor(*next_minor, reserved));
|
||||
let class_minor = u16::try_from(class_minor_u32).unwrap_or(u16::MAX);
|
||||
let class_major = u16::try_from(group.queue).unwrap_or(u16::MAX);
|
||||
let up_class_major = class_major.saturating_add(constraints.stick_offset);
|
||||
reserved.insert(class_minor_u32);
|
||||
*next_minor = (*next_minor).max(class_minor_u32).saturating_add(1);
|
||||
|
||||
circuit_assignments.push(CircuitIdentityAssignment {
|
||||
circuit_id: circuit_id.clone(),
|
||||
parent_node: group.parent_node.clone(),
|
||||
queue: group.queue,
|
||||
class_minor,
|
||||
class_major,
|
||||
up_class_major,
|
||||
});
|
||||
circuit_state.insert(
|
||||
circuit_id.clone(),
|
||||
PlannerCircuitIdentityState {
|
||||
class_minor,
|
||||
queue: group.queue,
|
||||
parent_node: group.parent_node.clone(),
|
||||
class_major,
|
||||
up_class_major,
|
||||
},
|
||||
);
|
||||
}
|
||||
*next_minor = (*next_minor).saturating_add(constraints.circuit_padding);
|
||||
}
|
||||
|
||||
let mut last_used_minor_by_queue = BTreeMap::new();
|
||||
for queue in reserved_circuit_minors
|
||||
.keys()
|
||||
.chain(next_site_minor_by_queue.keys())
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeSet<u32>>()
|
||||
{
|
||||
let site_next = next_site_minor_by_queue.get(&queue).copied().unwrap_or(3);
|
||||
let circuit_next = next_circuit_minor_by_queue
|
||||
.get(&queue)
|
||||
.copied()
|
||||
.unwrap_or(3);
|
||||
last_used_minor_by_queue.insert(queue, site_next.max(circuit_next));
|
||||
}
|
||||
|
||||
ClassIdentityPlannerOutput {
|
||||
sites: site_assignments,
|
||||
circuits: circuit_assignments,
|
||||
site_state,
|
||||
circuit_state,
|
||||
last_used_minor_by_queue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plans site and circuit class identities using the default structural allocator policy.
|
||||
pub fn plan_class_identities(
|
||||
sites: &[SiteIdentityInput],
|
||||
circuit_groups: &[CircuitIdentityGroupInput],
|
||||
previous_sites: &BTreeMap<String, PlannerSiteIdentityState>,
|
||||
previous_circuits: &BTreeMap<String, PlannerCircuitIdentityState>,
|
||||
stick_offset: u16,
|
||||
circuit_padding: u32,
|
||||
) -> ClassIdentityPlannerOutput {
|
||||
let (reserved_site_minors, reserved_circuit_minors) =
|
||||
build_class_identity_reservations(sites, circuit_groups, previous_sites, previous_circuits);
|
||||
let constraints = ClassIdentityPlannerConstraints {
|
||||
reserved_site_minors,
|
||||
reserved_circuit_minors,
|
||||
site_minor_start: 3,
|
||||
circuit_minor_start: 3,
|
||||
stick_offset,
|
||||
circuit_padding,
|
||||
};
|
||||
plan_class_identities_with_constraints(
|
||||
sites,
|
||||
circuit_groups,
|
||||
previous_sites,
|
||||
previous_circuits,
|
||||
&constraints,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CircuitIdentityGroupInput, ClassIdentityPlannerConstraints, PlannerCircuitIdentityState,
|
||||
PlannerSiteIdentityState, SiteIdentityInput, TopLevelPlannerItem, TopLevelPlannerMode,
|
||||
TopLevelPlannerParams, build_class_identity_reservations, plan_class_identities,
|
||||
plan_class_identities_with_constraints, plan_top_level_assignments,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn bins() -> Vec<String> {
|
||||
vec!["CpueQueue0".to_string(), "CpueQueue1".to_string()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_robin_is_deterministic() {
|
||||
let items = vec![
|
||||
TopLevelPlannerItem {
|
||||
id: "b".to_string(),
|
||||
weight: 1.0,
|
||||
},
|
||||
TopLevelPlannerItem {
|
||||
id: "a".to_string(),
|
||||
weight: 1.0,
|
||||
},
|
||||
];
|
||||
let result = plan_top_level_assignments(
|
||||
&items,
|
||||
&bins(),
|
||||
&BTreeMap::new(),
|
||||
&BTreeMap::new(),
|
||||
0.0,
|
||||
&TopLevelPlannerParams {
|
||||
mode: TopLevelPlannerMode::RoundRobin,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result.assignment.get("a").map(String::as_str),
|
||||
Some("CpueQueue0")
|
||||
);
|
||||
assert_eq!(
|
||||
result.assignment.get("b").map(String::as_str),
|
||||
Some("CpueQueue1")
|
||||
);
|
||||
assert!(!result.planner_used);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_greedy_breaks_degenerate_single_bin_assignments() {
|
||||
let items = vec![
|
||||
TopLevelPlannerItem {
|
||||
id: "heavy".to_string(),
|
||||
weight: 100.0,
|
||||
},
|
||||
TopLevelPlannerItem {
|
||||
id: "light".to_string(),
|
||||
weight: 1.0,
|
||||
},
|
||||
];
|
||||
let prev_assign = BTreeMap::from([
|
||||
("heavy".to_string(), "CpueQueue0".to_string()),
|
||||
("light".to_string(), "CpueQueue0".to_string()),
|
||||
]);
|
||||
let last_change_ts =
|
||||
BTreeMap::from([("heavy".to_string(), 990.0), ("light".to_string(), 990.0)]);
|
||||
let result = plan_top_level_assignments(
|
||||
&items,
|
||||
&bins(),
|
||||
&prev_assign,
|
||||
&last_change_ts,
|
||||
1000.0,
|
||||
&TopLevelPlannerParams {
|
||||
mode: TopLevelPlannerMode::StableGreedy,
|
||||
cooldown_seconds: 3600.0,
|
||||
move_budget_per_run: 1,
|
||||
hysteresis_threshold: 0.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result.assignment.get("light").map(String::as_str),
|
||||
Some("CpueQueue1")
|
||||
);
|
||||
assert_eq!(result.changed, vec!["light".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_greedy_can_move_when_cooldown_expired() {
|
||||
let items = vec![
|
||||
TopLevelPlannerItem {
|
||||
id: "heavy".to_string(),
|
||||
weight: 100.0,
|
||||
},
|
||||
TopLevelPlannerItem {
|
||||
id: "light".to_string(),
|
||||
weight: 1.0,
|
||||
},
|
||||
];
|
||||
let prev_assign = BTreeMap::from([
|
||||
("heavy".to_string(), "CpueQueue0".to_string()),
|
||||
("light".to_string(), "CpueQueue0".to_string()),
|
||||
]);
|
||||
let result = plan_top_level_assignments(
|
||||
&items,
|
||||
&bins(),
|
||||
&prev_assign,
|
||||
&BTreeMap::new(),
|
||||
10_000.0,
|
||||
&TopLevelPlannerParams {
|
||||
mode: TopLevelPlannerMode::StableGreedy,
|
||||
cooldown_seconds: 0.0,
|
||||
move_budget_per_run: 1,
|
||||
hysteresis_threshold: 0.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result.assignment.get("light").map(String::as_str),
|
||||
Some("CpueQueue1")
|
||||
);
|
||||
assert_eq!(result.changed, vec!["light".to_string()]);
|
||||
assert!(result.planner_used);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_identity_planner_reuses_site_and_circuit_minors() {
|
||||
let site_inputs = vec![
|
||||
SiteIdentityInput {
|
||||
site_key: "CpueQueue0/site-a".to_string(),
|
||||
parent_path: "CpueQueue0".to_string(),
|
||||
queue: 1,
|
||||
has_children: true,
|
||||
},
|
||||
SiteIdentityInput {
|
||||
site_key: "CpueQueue0/site-a/child".to_string(),
|
||||
parent_path: "CpueQueue0/site-a".to_string(),
|
||||
queue: 1,
|
||||
has_children: false,
|
||||
},
|
||||
];
|
||||
let circuit_groups = vec![CircuitIdentityGroupInput {
|
||||
parent_node: "child".to_string(),
|
||||
queue: 1,
|
||||
circuit_ids: vec!["c1".to_string(), "c2".to_string()],
|
||||
}];
|
||||
let prev_sites = BTreeMap::from([
|
||||
(
|
||||
"CpueQueue0/site-a".to_string(),
|
||||
PlannerSiteIdentityState {
|
||||
class_minor: 0x20,
|
||||
queue: 1,
|
||||
parent_path: "CpueQueue0".to_string(),
|
||||
class_major: 1,
|
||||
up_class_major: 0x41,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CpueQueue0/site-a/child".to_string(),
|
||||
PlannerSiteIdentityState {
|
||||
class_minor: 0x22,
|
||||
queue: 1,
|
||||
parent_path: "CpueQueue0/site-a".to_string(),
|
||||
class_major: 1,
|
||||
up_class_major: 0x41,
|
||||
},
|
||||
),
|
||||
]);
|
||||
let prev_circuits = BTreeMap::from([(
|
||||
"c1".to_string(),
|
||||
PlannerCircuitIdentityState {
|
||||
class_minor: 0x30,
|
||||
queue: 1,
|
||||
parent_node: "child".to_string(),
|
||||
class_major: 1,
|
||||
up_class_major: 0x41,
|
||||
},
|
||||
)]);
|
||||
|
||||
let result = plan_class_identities(
|
||||
&site_inputs,
|
||||
&circuit_groups,
|
||||
&prev_sites,
|
||||
&prev_circuits,
|
||||
0x40,
|
||||
8,
|
||||
);
|
||||
|
||||
assert_eq!(result.sites[0].class_minor, 0x20);
|
||||
assert_eq!(result.sites[1].class_minor, 0x22);
|
||||
assert_eq!(result.circuits[0].class_minor, 0x30);
|
||||
assert_eq!(result.circuits[1].queue, 1);
|
||||
assert_eq!(result.circuits[1].class_major, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_identity_planner_reserves_untouched_previous_minors() {
|
||||
let site_inputs = vec![SiteIdentityInput {
|
||||
site_key: "CpueQueue6/site-moved".to_string(),
|
||||
parent_path: "CpueQueue6/site-parent".to_string(),
|
||||
queue: 7,
|
||||
has_children: false,
|
||||
}];
|
||||
let prev_sites = BTreeMap::from([
|
||||
(
|
||||
"CpueQueue6/site-untouched".to_string(),
|
||||
PlannerSiteIdentityState {
|
||||
class_minor: 0x29,
|
||||
queue: 7,
|
||||
parent_path: "".to_string(),
|
||||
class_major: 7,
|
||||
up_class_major: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CpueQueue0/site-moved".to_string(),
|
||||
PlannerSiteIdentityState {
|
||||
class_minor: 0x13,
|
||||
queue: 1,
|
||||
parent_path: "CpueQueue0/site-parent".to_string(),
|
||||
class_major: 1,
|
||||
up_class_major: 1,
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let result = plan_class_identities(&site_inputs, &[], &prev_sites, &BTreeMap::new(), 0, 0);
|
||||
|
||||
assert_eq!(result.sites.len(), 1);
|
||||
assert_eq!(result.sites[0].queue, 7);
|
||||
assert_ne!(result.sites[0].class_minor, 0x29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_identity_planner_runtime_constraints_prefer_runtime_band() {
|
||||
let site_inputs = vec![SiteIdentityInput {
|
||||
site_key: "CpueQueue6/site-runtime".to_string(),
|
||||
parent_path: "CpueQueue6/site-parent".to_string(),
|
||||
queue: 7,
|
||||
has_children: false,
|
||||
}];
|
||||
let prev_sites = BTreeMap::from([(
|
||||
"CpueQueue6/site-untouched".to_string(),
|
||||
PlannerSiteIdentityState {
|
||||
class_minor: 0x29,
|
||||
queue: 7,
|
||||
parent_path: "".to_string(),
|
||||
class_major: 7,
|
||||
up_class_major: 7,
|
||||
},
|
||||
)]);
|
||||
let (reserved_site_minors, reserved_circuit_minors) =
|
||||
build_class_identity_reservations(&site_inputs, &[], &prev_sites, &BTreeMap::new());
|
||||
let constraints = ClassIdentityPlannerConstraints {
|
||||
reserved_site_minors,
|
||||
reserved_circuit_minors,
|
||||
site_minor_start: 0x1000,
|
||||
circuit_minor_start: 0x1000,
|
||||
stick_offset: 0,
|
||||
circuit_padding: 0,
|
||||
};
|
||||
|
||||
let result = plan_class_identities_with_constraints(
|
||||
&site_inputs,
|
||||
&[],
|
||||
&BTreeMap::new(),
|
||||
&BTreeMap::new(),
|
||||
&constraints,
|
||||
);
|
||||
|
||||
assert_eq!(result.sites.len(), 1);
|
||||
assert!(result.sites[0].class_minor >= 0x1000);
|
||||
}
|
||||
}
|
||||
@@ -303,12 +303,14 @@ struct AddDeviceSpeedArgs {
|
||||
|
||||
#[derive(Args, Debug, Default)]
|
||||
struct AddSiteSpeedArgs {
|
||||
#[arg(long)]
|
||||
node_id: Option<String>,
|
||||
#[arg(long)]
|
||||
site_name: String,
|
||||
#[arg(long)]
|
||||
download_bandwidth_mbps: Option<u32>,
|
||||
download_bandwidth_mbps: Option<f32>,
|
||||
#[arg(long)]
|
||||
upload_bandwidth_mbps: Option<u32>,
|
||||
upload_bandwidth_mbps: Option<f32>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -419,6 +421,7 @@ fn main() -> Result<()> {
|
||||
Commands::NetworkAdjustments { command: cmd } => match cmd {
|
||||
NetworkAdjustmentsCommand::AddSiteSpeed(args) => {
|
||||
let adj = NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: args.node_id,
|
||||
site_name: args.site_name,
|
||||
download_bandwidth_mbps: args.download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps: args.upload_bandwidth_mbps,
|
||||
|
||||
@@ -87,17 +87,20 @@ pub enum CircuitAdjustment {
|
||||
}
|
||||
|
||||
/// A network-level override applied while generating `network.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum NetworkAdjustment {
|
||||
/// Replaces site bandwidth values for a named site.
|
||||
AdjustSiteSpeed {
|
||||
/// Optional stable node identifier to update.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
node_id: Option<String>,
|
||||
/// Site name to update.
|
||||
site_name: String,
|
||||
/// Replacement download bandwidth in Mbps.
|
||||
download_bandwidth_mbps: Option<u32>,
|
||||
download_bandwidth_mbps: Option<f32>,
|
||||
/// Replacement upload bandwidth in Mbps.
|
||||
upload_bandwidth_mbps: Option<u32>,
|
||||
upload_bandwidth_mbps: Option<f32>,
|
||||
},
|
||||
/// Marks a named node as virtual or non-virtual.
|
||||
SetNodeVirtual {
|
||||
@@ -368,52 +371,42 @@ fn merge_circuit_adjustments_owned(
|
||||
fn merge_network_adjustments_owned(
|
||||
operator_adjustments: &[NetworkAdjustment],
|
||||
stormguard_adjustments: &[NetworkAdjustment],
|
||||
treeguard_adjustments: &[NetworkAdjustment],
|
||||
_treeguard_adjustments: &[NetworkAdjustment],
|
||||
) -> Vec<NetworkAdjustment> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let mut stormguard_site_speeds: HashMap<&str, (Option<u32>, Option<u32>)> = HashMap::new();
|
||||
let mut stormguard_site_order: Vec<&str> = Vec::new();
|
||||
let mut stormguard_site_seen: HashSet<&str> = HashSet::new();
|
||||
let mut stormguard_site_speeds: HashMap<String, NetworkAdjustment> = HashMap::new();
|
||||
let mut stormguard_site_order: Vec<String> = Vec::new();
|
||||
let mut stormguard_site_seen: HashSet<String> = HashSet::new();
|
||||
for adj in stormguard_adjustments {
|
||||
if let NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id,
|
||||
site_name,
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
} = adj
|
||||
{
|
||||
let name = site_name.as_str();
|
||||
stormguard_site_speeds.insert(name, (*download_bandwidth_mbps, *upload_bandwidth_mbps));
|
||||
if !stormguard_site_seen.contains(name) {
|
||||
stormguard_site_order.push(name);
|
||||
stormguard_site_seen.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut treeguard_virtual: HashMap<&str, bool> = HashMap::new();
|
||||
let mut treeguard_virtual_order: Vec<&str> = Vec::new();
|
||||
let mut treeguard_virtual_seen: HashSet<&str> = HashSet::new();
|
||||
|
||||
for adj in treeguard_adjustments {
|
||||
if let NetworkAdjustment::SetNodeVirtual {
|
||||
node_name,
|
||||
virtual_node,
|
||||
} = adj
|
||||
{
|
||||
let name = node_name.as_str();
|
||||
treeguard_virtual.insert(name, *virtual_node);
|
||||
if !treeguard_virtual_seen.contains(name) {
|
||||
treeguard_virtual_order.push(name);
|
||||
treeguard_virtual_seen.insert(name);
|
||||
let key = site_speed_key(node_id.as_deref(), site_name);
|
||||
stormguard_site_speeds.insert(
|
||||
key.clone(),
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: node_id.clone(),
|
||||
site_name: site_name.clone(),
|
||||
download_bandwidth_mbps: *download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps: *upload_bandwidth_mbps,
|
||||
},
|
||||
);
|
||||
if !stormguard_site_seen.contains(&key) {
|
||||
stormguard_site_order.push(key.clone());
|
||||
stormguard_site_seen.insert(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut used_treeguard_virtual: HashSet<&str> = HashSet::new();
|
||||
let mut operator_virtual_seen: HashSet<&str> = HashSet::new();
|
||||
let mut operator_site_speed_seen: HashSet<&str> = HashSet::new();
|
||||
let mut operator_site_speed_seen: HashSet<String> = HashSet::new();
|
||||
let mut operator_site_name_seen: HashSet<String> = HashSet::new();
|
||||
|
||||
for adj in operator_adjustments {
|
||||
match adj {
|
||||
@@ -426,31 +419,25 @@ fn merge_network_adjustments_owned(
|
||||
continue;
|
||||
}
|
||||
operator_virtual_seen.insert(name);
|
||||
|
||||
if let Some(v) = treeguard_virtual.get(name) {
|
||||
out.push(NetworkAdjustment::SetNodeVirtual {
|
||||
node_name: node_name.clone(),
|
||||
virtual_node: *v,
|
||||
});
|
||||
used_treeguard_virtual.insert(name);
|
||||
} else {
|
||||
out.push(NetworkAdjustment::SetNodeVirtual {
|
||||
node_name: node_name.clone(),
|
||||
virtual_node: *virtual_node,
|
||||
});
|
||||
}
|
||||
out.push(NetworkAdjustment::SetNodeVirtual {
|
||||
node_name: node_name.clone(),
|
||||
virtual_node: *virtual_node,
|
||||
});
|
||||
}
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id,
|
||||
site_name,
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
} => {
|
||||
let name = site_name.as_str();
|
||||
if operator_site_speed_seen.contains(name) {
|
||||
let key = site_speed_key(node_id.as_deref(), site_name);
|
||||
if operator_site_speed_seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
operator_site_speed_seen.insert(name);
|
||||
operator_site_speed_seen.insert(key);
|
||||
operator_site_name_seen.insert(site_name.clone());
|
||||
out.push(NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: node_id.clone(),
|
||||
site_name: site_name.clone(),
|
||||
download_bandwidth_mbps: *download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps: *upload_bandwidth_mbps,
|
||||
@@ -459,33 +446,27 @@ fn merge_network_adjustments_owned(
|
||||
}
|
||||
}
|
||||
|
||||
for name in stormguard_site_order {
|
||||
if operator_site_speed_seen.contains(name) {
|
||||
for key in stormguard_site_order {
|
||||
if operator_site_speed_seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
let Some((download_bandwidth_mbps, upload_bandwidth_mbps)) =
|
||||
stormguard_site_speeds.get(name)
|
||||
let Some(NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id,
|
||||
site_name,
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
}) = stormguard_site_speeds.get(&key)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
out.push(NetworkAdjustment::AdjustSiteSpeed {
|
||||
site_name: name.to_string(),
|
||||
download_bandwidth_mbps: *download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps: *upload_bandwidth_mbps,
|
||||
});
|
||||
}
|
||||
|
||||
// Append TreeGuard-only virtual-node entries (ignore other network adjustments).
|
||||
for name in treeguard_virtual_order {
|
||||
if used_treeguard_virtual.contains(name) {
|
||||
if operator_site_name_seen.contains(site_name) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = treeguard_virtual.get(name) else {
|
||||
continue;
|
||||
};
|
||||
out.push(NetworkAdjustment::SetNodeVirtual {
|
||||
node_name: name.to_string(),
|
||||
virtual_node: *v,
|
||||
out.push(NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: node_id.clone(),
|
||||
site_name: site_name.clone(),
|
||||
download_bandwidth_mbps: *download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps: *upload_bandwidth_mbps,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -679,10 +660,11 @@ impl OverrideFile {
|
||||
} if current == &device_id
|
||||
)
|
||||
});
|
||||
self.circuit_adjustments.push(CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override: normalized,
|
||||
});
|
||||
self.circuit_adjustments
|
||||
.push(CircuitAdjustment::DeviceAdjustSqm {
|
||||
device_id,
|
||||
sqm_override: normalized,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
@@ -714,34 +696,95 @@ impl OverrideFile {
|
||||
self.network_adjustments.push(adj);
|
||||
}
|
||||
|
||||
/// Returns the stored site bandwidth override that best matches this node.
|
||||
///
|
||||
/// Preference order:
|
||||
/// 1. Exact `node_id` match when one is supplied.
|
||||
/// 2. Legacy name-only match for the same `site_name`.
|
||||
pub fn find_site_bandwidth_override(
|
||||
&self,
|
||||
node_id: Option<&str>,
|
||||
site_name: &str,
|
||||
) -> Option<&NetworkAdjustment> {
|
||||
if let Some(node_id) = node_id
|
||||
&& let Some(found) = self.network_adjustments.iter().find(|adj| {
|
||||
matches!(
|
||||
adj,
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: Some(current_node_id),
|
||||
..
|
||||
} if current_node_id == node_id
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some(found);
|
||||
}
|
||||
|
||||
self.network_adjustments.iter().find(|adj| {
|
||||
matches!(
|
||||
adj,
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: None,
|
||||
site_name: current_site_name,
|
||||
..
|
||||
} if current_site_name == site_name
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Add or replace a site bandwidth override for `site_name`.
|
||||
pub fn set_site_bandwidth_override(
|
||||
&mut self,
|
||||
node_id: Option<String>,
|
||||
site_name: String,
|
||||
download_bandwidth_mbps: Option<u32>,
|
||||
upload_bandwidth_mbps: Option<u32>,
|
||||
) {
|
||||
download_bandwidth_mbps: Option<f32>,
|
||||
upload_bandwidth_mbps: Option<f32>,
|
||||
) -> bool {
|
||||
let desired = NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: node_id.clone(),
|
||||
site_name: site_name.clone(),
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
};
|
||||
if self.find_site_bandwidth_override(node_id.as_deref(), &site_name) == Some(&desired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.network_adjustments.retain(|adj| match adj {
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
site_name: current, ..
|
||||
} => current != &site_name,
|
||||
node_id: current_node_id,
|
||||
site_name: current_site_name,
|
||||
..
|
||||
} => !site_speed_override_matches(
|
||||
current_node_id.as_deref(),
|
||||
current_site_name,
|
||||
node_id.as_deref(),
|
||||
&site_name,
|
||||
),
|
||||
_ => true,
|
||||
});
|
||||
self.network_adjustments
|
||||
.push(NetworkAdjustment::AdjustSiteSpeed {
|
||||
site_name,
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
});
|
||||
self.network_adjustments.push(desired);
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove any site bandwidth overrides for `site_name`. Returns number removed.
|
||||
pub fn remove_site_bandwidth_override_by_name_count(&mut self, site_name: &str) -> usize {
|
||||
/// Remove any site bandwidth overrides for `site_name` or `node_id`. Returns number removed.
|
||||
pub fn remove_site_bandwidth_override_count(
|
||||
&mut self,
|
||||
node_id: Option<&str>,
|
||||
site_name: &str,
|
||||
) -> usize {
|
||||
let before = self.network_adjustments.len();
|
||||
self.network_adjustments.retain(|adj| match adj {
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
site_name: current, ..
|
||||
} => current != site_name,
|
||||
node_id: current_node_id,
|
||||
site_name: current_site_name,
|
||||
..
|
||||
} => !site_speed_override_matches(
|
||||
current_node_id.as_deref(),
|
||||
current_site_name,
|
||||
node_id,
|
||||
site_name,
|
||||
),
|
||||
_ => true,
|
||||
});
|
||||
before.saturating_sub(self.network_adjustments.len())
|
||||
@@ -819,6 +862,29 @@ impl OverrideFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn site_speed_key(node_id: Option<&str>, site_name: &str) -> String {
|
||||
match node_id {
|
||||
Some(node_id) if !node_id.trim().is_empty() => format!("id:{node_id}"),
|
||||
_ => format!("name:{site_name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn site_speed_override_matches(
|
||||
current_node_id: Option<&str>,
|
||||
current_site_name: &str,
|
||||
requested_node_id: Option<&str>,
|
||||
requested_site_name: &str,
|
||||
) -> bool {
|
||||
if let Some(requested_node_id) = requested_node_id {
|
||||
if current_node_id == Some(requested_node_id) {
|
||||
return true;
|
||||
}
|
||||
return current_node_id.is_none() && current_site_name == requested_site_name;
|
||||
}
|
||||
|
||||
current_site_name == requested_site_name
|
||||
}
|
||||
|
||||
impl OverrideStore {
|
||||
/// Loads a single overrides layer.
|
||||
///
|
||||
@@ -930,18 +996,22 @@ mod tests {
|
||||
|
||||
let mut stormguard = OverrideFile::default();
|
||||
stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev1", "stormguard",
|
||||
"dev1",
|
||||
"stormguard",
|
||||
));
|
||||
stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev3", "stormguard",
|
||||
"dev3",
|
||||
"stormguard",
|
||||
));
|
||||
|
||||
let mut treeguard = OverrideFile::default();
|
||||
treeguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev1", "treeguard",
|
||||
"dev1",
|
||||
"treeguard",
|
||||
));
|
||||
treeguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment(
|
||||
"dev4", "treeguard",
|
||||
"dev4",
|
||||
"treeguard",
|
||||
));
|
||||
|
||||
let merged = merge_owned_sections(operator, stormguard, treeguard);
|
||||
@@ -1009,34 +1079,35 @@ mod tests {
|
||||
"dev1".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(of.set_device_sqm_override_return_changed(
|
||||
"dev1".to_string(),
|
||||
Some("cake".to_string())
|
||||
));
|
||||
assert!(
|
||||
of.set_device_sqm_override_return_changed("dev1".to_string(), Some("cake".to_string()))
|
||||
);
|
||||
assert_eq!(of.remove_device_sqm_override_by_device_count("dev1"), 1);
|
||||
assert_eq!(of.remove_device_sqm_override_by_device_count("dev1"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_merge_treeguard_wins_for_node_virtual_only() {
|
||||
fn effective_merge_keeps_operator_node_virtual_and_ignores_treeguard_runtime_virtualization() {
|
||||
let mut operator = OverrideFile::default();
|
||||
operator.set_network_node_virtual("NodeA".to_string(), false);
|
||||
operator.add_network_adjustment(NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: Some("node-site-1".to_string()),
|
||||
site_name: "Site1".to_string(),
|
||||
download_bandwidth_mbps: Some(100),
|
||||
upload_bandwidth_mbps: Some(50),
|
||||
download_bandwidth_mbps: Some(100.0),
|
||||
upload_bandwidth_mbps: Some(50.0),
|
||||
});
|
||||
|
||||
let mut stormguard = OverrideFile::default();
|
||||
stormguard.set_site_bandwidth_override("Site1".to_string(), Some(80), Some(40));
|
||||
stormguard.set_site_bandwidth_override("Site2".to_string(), Some(150), Some(75));
|
||||
stormguard.set_site_bandwidth_override(None, "Site1".to_string(), Some(80.0), Some(40.0));
|
||||
stormguard.set_site_bandwidth_override(None, "Site2".to_string(), Some(150.0), Some(75.0));
|
||||
|
||||
let mut treeguard = OverrideFile::default();
|
||||
treeguard.set_network_node_virtual("NodeA".to_string(), true);
|
||||
treeguard.add_network_adjustment(NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: Some("node-site-3".to_string()),
|
||||
site_name: "Site3".to_string(),
|
||||
download_bandwidth_mbps: Some(200),
|
||||
upload_bandwidth_mbps: Some(100),
|
||||
download_bandwidth_mbps: Some(200.0),
|
||||
upload_bandwidth_mbps: Some(100.0),
|
||||
});
|
||||
|
||||
let merged = merge_owned_sections(operator, stormguard, treeguard);
|
||||
@@ -1051,7 +1122,19 @@ mod tests {
|
||||
} if node_name == "NodeA" => Some(*virtual_node),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(node_a_virtual, Some(true));
|
||||
assert_eq!(node_a_virtual, Some(false));
|
||||
|
||||
let node_three_virtual = merged
|
||||
.network_adjustments()
|
||||
.iter()
|
||||
.find_map(|adj| match adj {
|
||||
NetworkAdjustment::SetNodeVirtual {
|
||||
node_name,
|
||||
virtual_node,
|
||||
} if node_name == "Node3" => Some(*virtual_node),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(node_three_virtual, None);
|
||||
|
||||
// Operator site speed should beat StormGuard; TreeGuard site speed should be ignored.
|
||||
let site_speed_names: Vec<&str> = merged
|
||||
@@ -1065,6 +1148,29 @@ mod tests {
|
||||
assert_eq!(site_speed_names, vec!["Site1", "Site2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_site_bandwidth_override_prefers_node_id_match() {
|
||||
let mut of = OverrideFile::default();
|
||||
of.set_site_bandwidth_override(None, "AP27".to_string(), Some(80.0), Some(40.0));
|
||||
of.set_site_bandwidth_override(
|
||||
Some("node-ap27".to_string()),
|
||||
"AP27".to_string(),
|
||||
Some(120.5),
|
||||
Some(60.25),
|
||||
);
|
||||
|
||||
let found = of.find_site_bandwidth_override(Some("node-ap27"), "AP27");
|
||||
assert!(matches!(
|
||||
found,
|
||||
Some(NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: Some(node_id),
|
||||
download_bandwidth_mbps: Some(download),
|
||||
upload_bandwidth_mbps: Some(upload),
|
||||
..
|
||||
}) if node_id == "node-ap27" && *download == 120.5 && *upload == 60.25
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_file_defaults_accept_empty_json() {
|
||||
let of: OverrideFile = serde_json::from_str("{}").expect("empty JSON should deserialize");
|
||||
|
||||
@@ -10,7 +10,9 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { workspace = true, features = ["extension-module"] }
|
||||
lqos_bakery = { path = "../lqos_bakery" }
|
||||
lqos_bus = { path = "../lqos_bus" }
|
||||
lqos_sys = { path = "../lqos_sys" }
|
||||
lqos_utils = { path = "../lqos_utils" }
|
||||
lqos_config = { path = "../lqos_config" }
|
||||
lqos_overrides = { path = "../lqos_overrides" }
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use lqos_bus::{BusRequest, BusResponse, bus_request};
|
||||
use anyhow::{Error, Result};
|
||||
use lqos_bus::{BusClientError, BusRequest, BusResponse, bus_request};
|
||||
use std::{
|
||||
thread::sleep,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub fn run_query(requests: Vec<BusRequest>) -> Result<Vec<BusResponse>> {
|
||||
let mut replies = Vec::with_capacity(8);
|
||||
@@ -11,3 +15,35 @@ pub fn run_query(requests: Vec<BusRequest>) -> Result<Vec<BusResponse>> {
|
||||
Ok(replies)
|
||||
})
|
||||
}
|
||||
|
||||
fn should_retry_bus_error(error: &Error) -> bool {
|
||||
let Some(bus_error) = error.downcast_ref::<BusClientError>() else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
bus_error,
|
||||
BusClientError::SocketNotFound
|
||||
| BusClientError::StreamReadError
|
||||
| BusClientError::StreamWriteError
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs a bus query, retrying briefly while the local bus socket is still starting up.
|
||||
pub fn run_query_wait_for_bus(
|
||||
requests: Vec<BusRequest>,
|
||||
timeout: Duration,
|
||||
retry_interval: Duration,
|
||||
) -> Result<Vec<BusResponse>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match run_query(requests.clone()) {
|
||||
Ok(replies) => return Ok(replies),
|
||||
Err(error) => {
|
||||
if !should_retry_bus_error(&error) || Instant::now() >= deadline {
|
||||
return Err(error);
|
||||
}
|
||||
sleep(retry_interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ pub(crate) fn get_weights_rust() -> Result<Vec<DeviceWeightResponse>> {
|
||||
let mut shaped_devices_weights = get_weights_from_shaped_devices()?;
|
||||
if use_lts_weights() {
|
||||
// This allows us to use Python printing
|
||||
println!("Using LTS weights");
|
||||
println!("Using Insight weights");
|
||||
|
||||
let config = load_config().unwrap();
|
||||
let org_key = config.long_term_stats.license_key.clone().unwrap();
|
||||
@@ -147,10 +147,10 @@ pub(crate) fn get_weights_rust() -> Result<Vec<DeviceWeightResponse>> {
|
||||
let duration_seconds = 60 * 60 * 24; // 1 day
|
||||
let percentile = 0.95;
|
||||
|
||||
eprintln!("Getting weights from LTS");
|
||||
eprintln!("Getting weights from Insight");
|
||||
let weights = get_weights_from_lts(&org_key, &node_id, start, duration_seconds, percentile);
|
||||
if let Ok(weights) = weights {
|
||||
eprintln!("Retrieved {} weights from LTS", weights.len());
|
||||
eprintln!("Retrieved {} weights from Insight", weights.len());
|
||||
// Merge them
|
||||
for weight in weights.iter() {
|
||||
if let Some(existing) = shaped_devices_weights
|
||||
@@ -161,7 +161,7 @@ pub(crate) fn get_weights_rust() -> Result<Vec<DeviceWeightResponse>> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Failed to get weights from LTS: {:?}", weights);
|
||||
eprintln!("Failed to get weights from Insight: {:?}", weights);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+994
-45
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
lqos_bus = { path = "../lqos_bus" }
|
||||
lqos_bakery = { path = "../lqos_bakery" }
|
||||
lqos_config = { path = "../lqos_config" }
|
||||
lqos_sys = { path = "../lqos_sys" }
|
||||
lqos_utils = { path = "../lqos_utils" }
|
||||
|
||||
@@ -20,11 +20,12 @@ const NUM_QUEUE_HISTORY: usize = 600;
|
||||
|
||||
pub use bus::get_raw_circuit_data;
|
||||
pub use interval::set_queue_refresh_interval;
|
||||
#[doc(hidden)]
|
||||
pub use queue_structure::{
|
||||
EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD,
|
||||
spawn_queue_structure_monitor,
|
||||
EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueNode,
|
||||
QueueStructure, spawn_queue_structure_monitor,
|
||||
};
|
||||
pub use queue_types::deserialize_tc_tree; // Exported for the benchmarker
|
||||
pub use tracking::spawn_queue_monitor;
|
||||
pub use tracking::{ALL_QUEUE_SUMMARY, QueueCounts, TOTAL_QUEUE_STATS};
|
||||
pub use tracking::{ALL_QUEUE_SUMMARY, QueueCounts, TOTAL_QUEUE_STATS, queue_stats_stale};
|
||||
pub use tracking::{add_watched_queue, still_watching};
|
||||
@@ -2,11 +2,13 @@ mod queing_structure_json_monitor;
|
||||
mod queue_network;
|
||||
mod queue_node;
|
||||
pub use queing_structure_json_monitor::spawn_queue_structure_monitor;
|
||||
#[doc(hidden)]
|
||||
pub use queing_structure_json_monitor::{
|
||||
EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD,
|
||||
EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueStructure,
|
||||
};
|
||||
use queue_network::QueueNetwork;
|
||||
pub(crate) use queue_node::QueueNode;
|
||||
#[doc(hidden)]
|
||||
pub use queue_node::QueueNode;
|
||||
use thiserror::Error;
|
||||
|
||||
pub(crate) fn read_queueing_structure() -> Result<Vec<QueueNode>, QueueStructureError> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::queue_structure::{
|
||||
queue_network::QueueNetwork, queue_node::QueueNode, read_queueing_structure,
|
||||
};
|
||||
use crate::tracking::ALL_QUEUE_SUMMARY;
|
||||
use arc_swap::ArcSwap;
|
||||
use lqos_utils::file_watcher::FileWatcher;
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -31,7 +30,9 @@ pub static EFFECTIVE_NODE_RATES: Lazy<ArcSwap<HashMap<String, (f64, f64)>>> = La
|
||||
/// to avoid circular dependencies.
|
||||
pub static QUEUE_STRUCTURE_CHANGED_STORMGUARD: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[derive(Clone)]
|
||||
/// Snapshot of the current flattened queue tree loaded from `queuingStructure.json`.
|
||||
pub struct QueueStructure {
|
||||
pub maybe_queues: Option<Vec<QueueNode>>,
|
||||
}
|
||||
@@ -94,7 +95,6 @@ fn update_queue_structure() {
|
||||
maybe_queues: Some(queues.clone()),
|
||||
};
|
||||
let effective_node_rates = build_effective_node_rates(&queues);
|
||||
ALL_QUEUE_SUMMARY.clear();
|
||||
QUEUE_STRUCTURE.store(Arc::new(new_queue_structure));
|
||||
EFFECTIVE_NODE_RATES.store(Arc::new(effective_node_rates));
|
||||
QUEUE_STRUCTURE_CHANGED_STORMGUARD.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -108,7 +108,6 @@ fn update_queue_structure() {
|
||||
warn!(
|
||||
"Failed to load queuingStructure.json ({err:?}); leaving queue structure unavailable"
|
||||
);
|
||||
ALL_QUEUE_SUMMARY.clear();
|
||||
QUEUE_STRUCTURE.store(Arc::new(QueueStructure { maybe_queues: None }));
|
||||
EFFECTIVE_NODE_RATES.store(Arc::new(HashMap::new()));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use lqos_utils::hex_string::read_hex_string;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct QueueNode {
|
||||
pub name: Option<String>,
|
||||
@@ -247,7 +248,7 @@ impl QueueNode {
|
||||
}
|
||||
return Err(QueueStructureError::Children);
|
||||
};
|
||||
result.circuits.push(n);
|
||||
result.children.push(n);
|
||||
}
|
||||
} else {
|
||||
warn!("Children was not an object");
|
||||
@@ -263,6 +264,9 @@ impl QueueNode {
|
||||
} else {
|
||||
warn!("Unable to parse node structure for [{key}]");
|
||||
}
|
||||
if result.name.is_none() && result.circuit_id.is_none() && result.device_id.is_none() {
|
||||
result.name = Some(key.to_string());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -290,13 +294,14 @@ impl QueueNode {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const EXAMPLE_QUEUE_STRUCTURE_WITH_CHILDREN: &str =
|
||||
include_str!("./example_queue_with_children.test.json");
|
||||
const EXAMPLE_QUEUE_STRUCTURE_NO_CHILDREN: &str =
|
||||
include_str!("./example_queue_flat.test.json");
|
||||
|
||||
fn try_load_queue_structure(raw_string: &str) {
|
||||
fn try_load_queue_structure(raw_string: &str) -> super::super::QueueNetwork {
|
||||
let mut result = super::super::QueueNetwork {
|
||||
cpu_node: Vec::new(),
|
||||
};
|
||||
@@ -318,6 +323,8 @@ mod test {
|
||||
} else {
|
||||
panic!("Unable to parse queueStructure.json");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -329,4 +336,62 @@ mod test {
|
||||
fn load_queue_structure_with_children() {
|
||||
try_load_queue_structure(EXAMPLE_QUEUE_STRUCTURE_WITH_CHILDREN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_key_nodes_preserve_names_when_name_field_is_absent() {
|
||||
let network = try_load_queue_structure(EXAMPLE_QUEUE_STRUCTURE_WITH_CHILDREN);
|
||||
|
||||
let flat = network.to_flat();
|
||||
let names: HashSet<String> = flat.iter().filter_map(|node| node.name.clone()).collect();
|
||||
|
||||
assert!(names.contains("Site_1"));
|
||||
assert!(names.contains("Site_3"));
|
||||
assert!(names.contains("PoP_5"));
|
||||
assert!(names.contains("PoP_6"));
|
||||
assert!(names.contains("AP_11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_are_tracked_as_children_not_circuits() {
|
||||
let network = try_load_queue_structure(EXAMPLE_QUEUE_STRUCTURE_WITH_CHILDREN);
|
||||
|
||||
let site_1 = network
|
||||
.cpu_node
|
||||
.iter()
|
||||
.find(|node| node.name.as_deref() == Some("Site_1"))
|
||||
.expect("Site_1 root should exist");
|
||||
|
||||
assert_eq!(site_1.children.len(), 2);
|
||||
assert!(site_1.circuits.is_empty());
|
||||
assert!(
|
||||
site_1
|
||||
.children
|
||||
.iter()
|
||||
.any(|child| child.name.as_deref() == Some("AP_A"))
|
||||
);
|
||||
assert!(
|
||||
site_1
|
||||
.children
|
||||
.iter()
|
||||
.any(|child| child.name.as_deref() == Some("Site_3"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattened_snapshot_supports_stormguard_style_name_lookup() {
|
||||
let network = try_load_queue_structure(EXAMPLE_QUEUE_STRUCTURE_WITH_CHILDREN);
|
||||
let flat = network.to_flat();
|
||||
|
||||
let target = flat
|
||||
.iter()
|
||||
.find(|node| node.name.as_deref() == Some("PoP_6"))
|
||||
.expect("PoP_6 should be resolvable by name");
|
||||
|
||||
assert_eq!(target.download_bandwidth_mbps, 60);
|
||||
assert_eq!(target.upload_bandwidth_mbps, 60);
|
||||
assert_eq!(
|
||||
target.class_id,
|
||||
TcHandle::from_string("0x1:0xb").expect("valid tc handle")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use std::collections::HashMap;
|
||||
pub struct QueueCounts {
|
||||
/// Number of CAKE queues.
|
||||
pub cake: usize,
|
||||
/// Number of fq_codel queues.
|
||||
pub fq_codel: usize,
|
||||
/// Number of HTB queues.
|
||||
pub htb: usize,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::{
|
||||
circuit_to_queue::CIRCUIT_TO_QUEUE, interval::QUEUE_MONITOR_INTERVAL, queue_store::QueueStore,
|
||||
tracking::reader::read_named_queue_from_interface,
|
||||
};
|
||||
use lqos_bakery::full_reload_in_progress;
|
||||
use lqos_utils::fdtimer::periodic;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use timerfd::{SetTimeFlags, TimerFd, TimerState};
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -18,7 +20,25 @@ use crate::tracking::reader::read_all_queues_from_interface;
|
||||
use watched_queues::WATCHED_QUEUES;
|
||||
pub use watched_queues::{add_watched_queue, still_watching};
|
||||
|
||||
static QUEUE_STATS_STALE: AtomicBool = AtomicBool::new(false);
|
||||
const ALL_QUEUE_MONITOR_INTERVAL_SECONDS: u64 = 2;
|
||||
const RELOAD_QUEUE_MONITOR_INTERVAL_SECONDS: u64 = 10;
|
||||
|
||||
/// Returns `true` when queue counts are intentionally held at their last-known values.
|
||||
///
|
||||
/// This is currently used during Bakery full reloads, where shelling out to `tc`
|
||||
/// for full-queue snapshots is too expensive to provide reliable live counts.
|
||||
pub fn queue_stats_stale() -> bool {
|
||||
QUEUE_STATS_STALE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn track_queues() {
|
||||
if full_reload_in_progress() {
|
||||
QUEUE_STATS_STALE.store(true, Ordering::Relaxed);
|
||||
debug!("(TC monitor) Skipping watched queue reads during Bakery full reload");
|
||||
expire_watched_queues();
|
||||
return;
|
||||
}
|
||||
if WATCHED_QUEUES.is_empty() {
|
||||
//info!("No queues marked for read.");
|
||||
return; // There's nothing to do - bail out fast
|
||||
@@ -83,6 +103,7 @@ fn count_queue_types(queues: &[QueueType]) -> QueueCounts {
|
||||
for queue in queues.iter() {
|
||||
match queue {
|
||||
QueueType::Cake(_) => counts.cake += 1,
|
||||
QueueType::FqCodel(_) => counts.fq_codel += 1,
|
||||
QueueType::Htb(_) => counts.htb += 1,
|
||||
_ => {}
|
||||
}
|
||||
@@ -153,6 +174,12 @@ fn connect_queues_to_circuit_up(
|
||||
|
||||
fn all_queue_reader() {
|
||||
let start = Instant::now();
|
||||
if full_reload_in_progress() {
|
||||
QUEUE_STATS_STALE.store(true, Ordering::Relaxed);
|
||||
debug!("(TC monitor) Skipping full queue read during Bakery full reload");
|
||||
return;
|
||||
}
|
||||
|
||||
let structure = QUEUE_STRUCTURE.load();
|
||||
if let Some(structure) = &structure.maybe_queues {
|
||||
if let Ok(config) = lqos_config::load_config() {
|
||||
@@ -191,6 +218,7 @@ fn all_queue_reader() {
|
||||
.unwrap_or_default();
|
||||
let counts = QueueCounts {
|
||||
cake: counts_down.cake + counts_up.cake,
|
||||
fq_codel: counts_down.fq_codel + counts_up.fq_codel,
|
||||
htb: counts_down.htb + counts_up.htb,
|
||||
};
|
||||
(download, upload, counts)
|
||||
@@ -198,10 +226,13 @@ fn all_queue_reader() {
|
||||
|
||||
//println!("{}", download.len() + upload.len());
|
||||
ALL_QUEUE_SUMMARY.ingest_batch(download, upload, queue_counts);
|
||||
QUEUE_STATS_STALE.store(false, Ordering::Relaxed);
|
||||
} else {
|
||||
QUEUE_STATS_STALE.store(true, Ordering::Relaxed);
|
||||
warn!("(TC monitor) Unable to read configuration");
|
||||
}
|
||||
} else {
|
||||
QUEUE_STATS_STALE.store(true, Ordering::Relaxed);
|
||||
warn!("(TC monitor) Not reading queues due to structure not yet ready");
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
@@ -238,7 +269,7 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("All Queue Monitor".to_string())
|
||||
.spawn(|| {
|
||||
let mut interval_seconds = 2;
|
||||
let mut interval_seconds = ALL_QUEUE_MONITOR_INTERVAL_SECONDS;
|
||||
let Ok(mut tfd) = TimerFd::new() else {
|
||||
error!("Unable to start timer file descriptor. All queue monitor cannot run.");
|
||||
return;
|
||||
@@ -246,7 +277,7 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> {
|
||||
assert_eq!(tfd.get_state(), TimerState::Disarmed);
|
||||
tfd.set_state(
|
||||
TimerState::Periodic {
|
||||
current: Duration::new(2, 0),
|
||||
current: Duration::new(interval_seconds, 0),
|
||||
interval: Duration::new(interval_seconds, 0),
|
||||
},
|
||||
SetTimeFlags::Default,
|
||||
@@ -254,13 +285,34 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> {
|
||||
let _ = tfd.read(); // Initial pause
|
||||
|
||||
loop {
|
||||
all_queue_reader();
|
||||
let desired_interval_seconds = if full_reload_in_progress() {
|
||||
RELOAD_QUEUE_MONITOR_INTERVAL_SECONDS
|
||||
} else {
|
||||
ALL_QUEUE_MONITOR_INTERVAL_SECONDS
|
||||
};
|
||||
if interval_seconds != desired_interval_seconds {
|
||||
interval_seconds = desired_interval_seconds;
|
||||
tfd.set_state(
|
||||
TimerState::Periodic {
|
||||
current: Duration::new(interval_seconds, 0),
|
||||
interval: Duration::new(interval_seconds, 0),
|
||||
},
|
||||
SetTimeFlags::Default,
|
||||
);
|
||||
}
|
||||
|
||||
if full_reload_in_progress() {
|
||||
QUEUE_STATS_STALE.store(true, Ordering::Relaxed);
|
||||
debug!("(TC monitor) Backing off full queue reads during Bakery full reload");
|
||||
} else {
|
||||
all_queue_reader();
|
||||
}
|
||||
|
||||
// Sleep until the next second
|
||||
let missed_ticks = tfd.read();
|
||||
if missed_ticks > 1 {
|
||||
warn!("All Queue Reader: Missed {} ticks", missed_ticks - 1);
|
||||
interval_seconds = 2 + (missed_ticks - 1);
|
||||
interval_seconds = desired_interval_seconds + (missed_ticks - 1);
|
||||
tfd.set_state(
|
||||
TimerState::Periodic {
|
||||
current: Duration::new(interval_seconds, 0),
|
||||
@@ -274,3 +326,63 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::connect_queues_to_circuit;
|
||||
use crate::{QueueNode, deserialize_tc_tree};
|
||||
use lqos_bus::TcHandle;
|
||||
use lqos_utils::hash_to_i64;
|
||||
|
||||
fn test_structure() -> Vec<QueueNode> {
|
||||
vec![QueueNode {
|
||||
class_id: TcHandle::from_string("3:14af").expect("valid class id"),
|
||||
up_class_id: TcHandle::from_string("43:14af").expect("valid up class id"),
|
||||
parent_class_id: TcHandle::from_string("3:20").expect("valid parent"),
|
||||
up_parent_class_id: TcHandle::from_string("43:20").expect("valid up parent"),
|
||||
class_major: 0x0003,
|
||||
up_class_major: 0x0043,
|
||||
class_minor: 0x14af,
|
||||
circuit_id: Some("circuit-1".to_string()),
|
||||
circuit_hash: Some(hash_to_i64("circuit-1")),
|
||||
..QueueNode::default()
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cake_queue_matching_uses_parent_not_leaf_handle() {
|
||||
let structure = test_structure();
|
||||
let old_handle_queues = deserialize_tc_tree(
|
||||
r#"[{
|
||||
"kind":"cake",
|
||||
"handle":"9000:",
|
||||
"parent":"3:14af",
|
||||
"options":{},
|
||||
"tins":[],
|
||||
"drops":7
|
||||
}]"#,
|
||||
)
|
||||
.expect("old-handle cake should parse");
|
||||
let rotated_handle_queues = deserialize_tc_tree(
|
||||
r#"[{
|
||||
"kind":"cake",
|
||||
"handle":"9002:",
|
||||
"parent":"3:14af",
|
||||
"options":{},
|
||||
"tins":[],
|
||||
"drops":11
|
||||
}]"#,
|
||||
)
|
||||
.expect("rotated-handle cake should parse");
|
||||
|
||||
let old_matches = connect_queues_to_circuit(&structure, &old_handle_queues);
|
||||
let rotated_matches = connect_queues_to_circuit(&structure, &rotated_handle_queues);
|
||||
|
||||
assert_eq!(old_matches.len(), 1);
|
||||
assert_eq!(rotated_matches.len(), 1);
|
||||
assert_eq!(old_matches[0].circuit_hash, hash_to_i64("circuit-1"));
|
||||
assert_eq!(rotated_matches[0].circuit_hash, hash_to_i64("circuit-1"));
|
||||
assert_eq!(old_matches[0].drops, 7);
|
||||
assert_eq!(rotated_matches[0].drops, 11);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ pub enum CircuitFallbackOutcome {
|
||||
|
||||
pub struct SiteOverrideUpdate {
|
||||
pub site_name: String,
|
||||
pub download_bandwidth_mbps: Option<u32>,
|
||||
pub upload_bandwidth_mbps: Option<u32>,
|
||||
pub download_bandwidth_mbps: Option<f32>,
|
||||
pub upload_bandwidth_mbps: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -36,7 +36,7 @@ pub fn apply_site_override_updates(updates: &[SiteOverrideUpdate]) -> Result<boo
|
||||
for update in updates {
|
||||
let desired = (update.download_bandwidth_mbps, update.upload_bandwidth_mbps);
|
||||
if desired == (None, None) {
|
||||
let removed = overrides.remove_site_bandwidth_override_by_name_count(&update.site_name);
|
||||
let removed = overrides.remove_site_bandwidth_override_count(None, &update.site_name);
|
||||
if removed > 0 {
|
||||
changed = true;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ pub fn apply_site_override_updates(updates: &[SiteOverrideUpdate]) -> Result<boo
|
||||
}
|
||||
|
||||
overrides.set_site_bandwidth_override(
|
||||
None,
|
||||
update.site_name.clone(),
|
||||
update.download_bandwidth_mbps,
|
||||
update.upload_bandwidth_mbps,
|
||||
@@ -142,12 +143,13 @@ pub fn load_persisted_circuit_fallbacks() -> Result<HashMap<String, PersistedCir
|
||||
fn current_site_override(
|
||||
overrides: &OverrideFile,
|
||||
site_name: &str,
|
||||
) -> Option<(Option<u32>, Option<u32>)> {
|
||||
) -> Option<(Option<f32>, Option<f32>)> {
|
||||
overrides
|
||||
.network_adjustments()
|
||||
.iter()
|
||||
.find_map(|adj| match adj {
|
||||
lqos_overrides::NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: _,
|
||||
site_name: current,
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
@@ -298,6 +300,11 @@ fn apply_circuit_sqm_override_live(
|
||||
|
||||
bakery_sender.send(BakeryCommands::AddCircuit {
|
||||
circuit_hash: hash_to_i64(circuit_id),
|
||||
circuit_name: node
|
||||
.circuit_name
|
||||
.clone()
|
||||
.or_else(|| Some(circuit_id.to_string())),
|
||||
site_name: node.parent_node.clone(),
|
||||
parent_class_id: node.parent_class_id,
|
||||
up_parent_class_id: node.up_parent_class_id,
|
||||
class_minor,
|
||||
@@ -307,6 +314,8 @@ fn apply_circuit_sqm_override_live(
|
||||
upload_bandwidth_max: node.upload_bandwidth_mbps as f32,
|
||||
class_major,
|
||||
up_class_major,
|
||||
down_qdisc_handle: None,
|
||||
up_qdisc_handle: None,
|
||||
ip_addresses: ip_list(devices),
|
||||
sqm_override: sqm_override.map(|value| value.to_string()),
|
||||
})?;
|
||||
@@ -357,12 +366,13 @@ fn group_circuit_fallbacks(
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = grouped
|
||||
.entry(device.circuit_id.clone())
|
||||
.or_insert_with(|| PersistedCircuitFallback {
|
||||
sqm_override: token.to_string(),
|
||||
devices: Vec::new(),
|
||||
});
|
||||
let entry =
|
||||
grouped
|
||||
.entry(device.circuit_id.clone())
|
||||
.or_insert_with(|| PersistedCircuitFallback {
|
||||
sqm_override: token.to_string(),
|
||||
devices: Vec::new(),
|
||||
});
|
||||
if entry.sqm_override != token {
|
||||
continue;
|
||||
}
|
||||
@@ -434,10 +444,12 @@ mod tests {
|
||||
"dev2".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev3".to_string(),
|
||||
Some("cake".to_string())
|
||||
));
|
||||
assert!(
|
||||
overrides.set_device_sqm_override_return_changed(
|
||||
"dev3".to_string(),
|
||||
Some("cake".to_string())
|
||||
)
|
||||
);
|
||||
|
||||
let grouped = group_circuit_fallbacks(&overrides, &[d1, d2, d3]);
|
||||
assert_eq!(grouped.len(), 2);
|
||||
@@ -479,14 +491,16 @@ mod tests {
|
||||
"dev1".to_string(),
|
||||
Some("fq_codel".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev2".to_string(),
|
||||
Some("cake".to_string())
|
||||
));
|
||||
assert!(overrides.set_device_sqm_override_return_changed(
|
||||
"dev3".to_string(),
|
||||
Some(" ".to_string())
|
||||
));
|
||||
assert!(
|
||||
overrides.set_device_sqm_override_return_changed(
|
||||
"dev2".to_string(),
|
||||
Some("cake".to_string())
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
overrides
|
||||
.set_device_sqm_override_return_changed("dev3".to_string(), Some(" ".to_string()))
|
||||
);
|
||||
|
||||
let grouped = group_circuit_fallbacks(&overrides, &[d1, d2, d3]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -130,7 +130,7 @@ pub fn configure() -> anyhow::Result<StormguardConfig> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn load_stormguard_site_overrides() -> HashMap<String, (Option<u32>, Option<u32>)> {
|
||||
fn load_stormguard_site_overrides() -> HashMap<String, (Option<f32>, Option<f32>)> {
|
||||
let Ok(overrides) = OverrideStore::load_layer(OverrideLayer::Stormguard) else {
|
||||
warn!("Unable to load StormGuard override layer; starting from planned rates.");
|
||||
return HashMap::new();
|
||||
@@ -141,6 +141,7 @@ fn load_stormguard_site_overrides() -> HashMap<String, (Option<u32>, Option<u32>
|
||||
.iter()
|
||||
.filter_map(|adj| match adj {
|
||||
NetworkAdjustment::AdjustSiteSpeed {
|
||||
node_id: _,
|
||||
site_name,
|
||||
download_bandwidth_mbps,
|
||||
upload_bandwidth_mbps,
|
||||
@@ -155,7 +156,7 @@ fn load_stormguard_site_overrides() -> HashMap<String, (Option<u32>, Option<u32>
|
||||
|
||||
fn get_sites_from_queueing_structure(
|
||||
sg_config: &lqos_config::StormguardConfig,
|
||||
persisted_site_overrides: &HashMap<String, (Option<u32>, Option<u32>)>,
|
||||
persisted_site_overrides: &HashMap<String, (Option<f32>, Option<f32>)>,
|
||||
) -> HashMap<String, WatchingSite> {
|
||||
let mut selected: Vec<String> = if sg_config.all_sites {
|
||||
all_candidate_site_names()
|
||||
@@ -196,12 +197,12 @@ fn get_sites_from_queueing_structure(
|
||||
.unwrap_or((None, None));
|
||||
let current_download_mbps = persisted
|
||||
.0
|
||||
.map(u64::from)
|
||||
.map(|mbps| mbps.max(0.0) as u64)
|
||||
.unwrap_or(max_down)
|
||||
.clamp(min_down, max_down);
|
||||
let current_upload_mbps = persisted
|
||||
.1
|
||||
.map(u64::from)
|
||||
.map(|mbps| mbps.max(0.0) as u64)
|
||||
.unwrap_or(max_up)
|
||||
.clamp(min_up, max_up);
|
||||
|
||||
|
||||
@@ -836,9 +836,9 @@ impl SiteStateTracker {
|
||||
SiteOverrideUpdate {
|
||||
site_name: site.config.name.clone(),
|
||||
download_bandwidth_mbps: (site.queue_download_mbps != site.config.max_download_mbps)
|
||||
.then_some(site.queue_download_mbps as u32),
|
||||
.then_some(site.queue_download_mbps as f32),
|
||||
upload_bandwidth_mbps: (site.queue_upload_mbps != site.config.max_upload_mbps)
|
||||
.then_some(site.queue_upload_mbps as u32),
|
||||
.then_some(site.queue_upload_mbps as f32),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,7 +1019,7 @@ mod tests {
|
||||
fn site_override_update_keeps_only_changed_directions() {
|
||||
let site = site_state(75, 50, 100, 50);
|
||||
let update = SiteStateTracker::site_override_update_from_state(&site);
|
||||
assert_eq!(update.download_bandwidth_mbps, Some(75));
|
||||
assert_eq!(update.download_bandwidth_mbps, Some(75.0));
|
||||
assert_eq!(update.upload_bandwidth_mbps, None);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,37 @@ fn command_warnings_errors_only(section: &str, command_result: &std::io::Result<
|
||||
}
|
||||
}
|
||||
|
||||
fn require_successful_output(section: &str, command_result: std::io::Result<Output>) -> Output {
|
||||
match command_result {
|
||||
Ok(output) => {
|
||||
if !output.stderr.is_empty() {
|
||||
println!(
|
||||
"cargo:warning=[{section}] {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"{section} failed with status {}",
|
||||
output
|
||||
.status
|
||||
.code()
|
||||
.map_or_else(|| "signal".to_string(), |code| code.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Err(error) => {
|
||||
panic!(
|
||||
"{section} is required to build lqos_sys but was not available: {error}. \
|
||||
Install bpftool (or provide it in PATH) before building."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var_os("OUT_DIR").unwrap();
|
||||
|
||||
@@ -84,7 +115,8 @@ fn main() {
|
||||
.args(["gen", "skeleton", &link_target])
|
||||
.output();
|
||||
command_warnings_errors_only("bpf skel", &skel_result);
|
||||
let header_file = String::from_utf8(skel_result.unwrap().stdout).unwrap();
|
||||
let skel_output = require_successful_output("bpftool gen skeleton", skel_result);
|
||||
let header_file = String::from_utf8(skel_output.stdout).unwrap();
|
||||
std::fs::write(skel_target, header_file).unwrap();
|
||||
|
||||
// 4: Copy the wrapper to our out dir
|
||||
|
||||
@@ -32,6 +32,7 @@ pub use ip_mapping::{
|
||||
pub use kernel_wrapper::LibreQoSKernels;
|
||||
pub use linux::num_possible_cpus;
|
||||
pub use lqos_kernel::interface_name_to_index;
|
||||
pub use lqos_kernel::ip_mapping_capacity;
|
||||
pub use lqos_kernel::max_tracked_ips;
|
||||
pub use lqos_kernel::unload_xdp_from_interface;
|
||||
pub use throughput::{HostCounter, throughput_for_each};
|
||||
@@ -37,6 +37,49 @@ pub fn max_tracked_ips() -> usize {
|
||||
(unsafe { bpf::max_tracker_ips() }) as usize
|
||||
}
|
||||
|
||||
fn pinned_map_max_entries(path: &str) -> Result<Option<u32>> {
|
||||
if !Path::new(path).exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let path_c = CString::new(path)?;
|
||||
let fd = unsafe { bpf_obj_get(path_c.as_ptr()) };
|
||||
if fd < 0 {
|
||||
warn!("Unable to open pinned BPF map '{path}' for capacity check (fd={fd})");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut info = MaybeUninit::<bpf_map_info>::zeroed();
|
||||
let mut len: u32 = std::mem::size_of::<bpf_map_info>() as u32;
|
||||
let err = unsafe { bpf_obj_get_info_by_fd(fd, info.as_mut_ptr() as *mut c_void, &mut len) };
|
||||
unsafe {
|
||||
close(fd);
|
||||
}
|
||||
if err != 0 {
|
||||
return Err(Error::msg(format!(
|
||||
"Unable to query pinned BPF map '{path}' capacity (err={err})."
|
||||
)));
|
||||
}
|
||||
|
||||
let info = unsafe { info.assume_init() };
|
||||
Ok(Some(info.max_entries))
|
||||
}
|
||||
|
||||
/// Returns the currently available IP-mapping capacity.
|
||||
///
|
||||
/// If the live pinned map exists, this returns its `max_entries` value.
|
||||
/// Otherwise, it falls back to the compiled `MAX_TRACKED_IPS` constant.
|
||||
pub fn ip_mapping_capacity() -> usize {
|
||||
match pinned_map_max_entries("/sys/fs/bpf/map_ip_to_cpu_and_tc") {
|
||||
Ok(Some(entries)) => entries as usize,
|
||||
Ok(None) => max_tracked_ips(),
|
||||
Err(e) => {
|
||||
warn!("Unable to determine live IP-mapping capacity: {e:?}");
|
||||
max_tracked_ips()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_root() -> Result<()> {
|
||||
unsafe {
|
||||
if geteuid() == 0 {
|
||||
|
||||
@@ -8,6 +8,120 @@ use serde::{Deserialize, Serialize};
|
||||
use std::ops::AddAssign;
|
||||
use zerocopy::FromBytes;
|
||||
|
||||
/// Strongly-typed count of TCP retransmit events.
|
||||
#[repr(transparent)]
|
||||
#[derive(
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Ord,
|
||||
PartialOrd,
|
||||
Hash,
|
||||
Allocative,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct RetransmitCount(pub u64);
|
||||
|
||||
impl RetransmitCount {
|
||||
/// Returns the underlying retransmit count.
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Strongly-typed count of TCP packets used as the retransmit denominator.
|
||||
#[repr(transparent)]
|
||||
#[derive(
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Ord,
|
||||
PartialOrd,
|
||||
Hash,
|
||||
Allocative,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TcpPacketCount(pub u64);
|
||||
|
||||
impl TcpPacketCount {
|
||||
/// Returns the underlying packet count.
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Strongly-typed retransmit fraction in the inclusive range `0.0..=1.0`.
|
||||
#[repr(transparent)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, PartialEq, PartialOrd, Serialize, Deserialize, Allocative,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct RetransmitFraction(f64);
|
||||
|
||||
impl RetransmitFraction {
|
||||
/// Constructs a retransmit fraction if the provided value is within `0.0..=1.0`.
|
||||
pub fn new(value: f64) -> Option<Self> {
|
||||
if (0.0..=1.0).contains(&value) {
|
||||
Some(Self(value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the underlying fraction value.
|
||||
pub const fn get(self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Converts the fraction to a `0.0..=100.0` percentage.
|
||||
pub fn percent_0_to_100(self) -> f64 {
|
||||
self.0 * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps TCP retransmit numerator and denominator together so callers cannot mix units.
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Allocative)]
|
||||
pub struct TcpRetransmitSample {
|
||||
/// The retransmit count for the sample window.
|
||||
pub retransmits: RetransmitCount,
|
||||
/// The TCP packet count for the same sample window.
|
||||
pub packets: TcpPacketCount,
|
||||
}
|
||||
|
||||
impl TcpRetransmitSample {
|
||||
/// Creates a new retransmit sample from raw counts.
|
||||
pub const fn new(retransmits: u64, packets: u64) -> Self {
|
||||
Self {
|
||||
retransmits: RetransmitCount(retransmits),
|
||||
packets: TcpPacketCount(packets),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the retransmit fraction for the sample window.
|
||||
pub fn fraction(self) -> Option<RetransmitFraction> {
|
||||
let packets = self.packets.get();
|
||||
if packets == 0 {
|
||||
return None;
|
||||
}
|
||||
RetransmitFraction::new(self.retransmits.get() as f64 / packets as f64)
|
||||
}
|
||||
|
||||
/// Computes the retransmit percentage for the sample window.
|
||||
pub fn percent_0_to_100(self) -> Option<f64> {
|
||||
self.fraction().map(RetransmitFraction::percent_0_to_100)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides strong download/upload separation for
|
||||
/// stored statistics to eliminate confusion. This is a generic
|
||||
/// type: you can control the type stored inside.
|
||||
@@ -33,6 +147,44 @@ pub struct DownUpOrder<T> {
|
||||
pub up: T,
|
||||
}
|
||||
|
||||
impl<T> DownUpOrder<T> {
|
||||
/// Create a new DownUpOrder with the given down and up values.
|
||||
pub const fn new(down: T, up: T) -> Self {
|
||||
Self { down, up }
|
||||
}
|
||||
|
||||
/// In the C code, it's common to refer to a "direction" byte:
|
||||
///
|
||||
/// * 0: down
|
||||
/// * 1: up
|
||||
/// * >1: error
|
||||
///
|
||||
/// This is a helper function to translate that byte into the
|
||||
/// appropriate value.
|
||||
pub fn dir(&self, direction: usize) -> T
|
||||
where
|
||||
T: Copy,
|
||||
{
|
||||
if direction == 0 { self.down } else { self.up }
|
||||
}
|
||||
|
||||
/// Get the `down` value.
|
||||
pub fn get_down(&self) -> T
|
||||
where
|
||||
T: Copy,
|
||||
{
|
||||
self.down
|
||||
}
|
||||
|
||||
/// Get the `up` value.
|
||||
pub fn get_up(&self) -> T
|
||||
where
|
||||
T: Copy,
|
||||
{
|
||||
self.up
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DownUpOrder<T>
|
||||
where
|
||||
T: std::cmp::Ord
|
||||
@@ -46,23 +198,6 @@ where
|
||||
+ num_traits::SaturatingAdd
|
||||
+ Default,
|
||||
{
|
||||
/// Create a new DownUpOrder with the given down and up values.
|
||||
pub fn new(down: T, up: T) -> Self {
|
||||
Self { down, up }
|
||||
}
|
||||
|
||||
/// In the C code, it's common to refer to a "direction" byte:
|
||||
///
|
||||
/// * 0: down
|
||||
/// * 1: up
|
||||
/// * >1: error
|
||||
///
|
||||
/// This is a helper function to translate that byte into the
|
||||
/// appropriate value.
|
||||
pub fn dir(&self, direction: usize) -> T {
|
||||
if direction == 0 { self.down } else { self.up }
|
||||
}
|
||||
|
||||
/// Return a new DownUpOrder with both down and up set to zero.
|
||||
pub fn zeroed() -> Self {
|
||||
Self {
|
||||
@@ -127,16 +262,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the `down` value.
|
||||
pub fn get_down(&self) -> T {
|
||||
self.down
|
||||
}
|
||||
|
||||
/// Get the `up` value.
|
||||
pub fn get_up(&self) -> T {
|
||||
self.up
|
||||
}
|
||||
|
||||
/// Set both the `down` and `up` values to zero.
|
||||
pub fn set_to_zero(&mut self) {
|
||||
self.down = T::zero();
|
||||
@@ -177,6 +302,17 @@ pub fn down_up_divide(left: DownUpOrder<u64>, right: DownUpOrder<u64>) -> (f64,
|
||||
(safe_div(left.down, right.down), safe_div(left.up, right.up))
|
||||
}
|
||||
|
||||
/// Pairs retransmit counts with TCP packet counts in the same directional ordering.
|
||||
pub fn down_up_retransmit_sample(
|
||||
retransmits: DownUpOrder<u64>,
|
||||
packets: DownUpOrder<u64>,
|
||||
) -> DownUpOrder<TcpRetransmitSample> {
|
||||
DownUpOrder::new(
|
||||
TcpRetransmitSample::new(retransmits.down, packets.down),
|
||||
TcpRetransmitSample::new(retransmits.up, packets.up),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -226,4 +362,23 @@ mod tests {
|
||||
assert_eq!(a.down, 2);
|
||||
assert_eq!(a.up, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retransmit_fraction_rejects_invalid_values() {
|
||||
assert!(RetransmitFraction::new(-0.1).is_none());
|
||||
assert!(RetransmitFraction::new(1.1).is_none());
|
||||
assert_eq!(RetransmitFraction::new(0.25).map(|f| f.get()), Some(0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retransmit_sample_computes_fraction_and_percent() {
|
||||
let sample = TcpRetransmitSample::new(5, 200);
|
||||
let fraction = sample.fraction().expect("fraction should exist");
|
||||
assert_eq!(fraction.get(), 0.025);
|
||||
assert_eq!(sample.percent_0_to_100(), Some(2.5));
|
||||
|
||||
let empty = TcpRetransmitSample::new(5, 0);
|
||||
assert!(empty.fraction().is_none());
|
||||
assert!(empty.percent_0_to_100().is_none());
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ nix = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
itertools = "0.13.0"
|
||||
reqwest = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
flate2 = "1"
|
||||
bincode = { workspace = true }
|
||||
ip_network_table = { workspace = true }
|
||||
|
||||
@@ -20,6 +20,13 @@ pub use messages::{
|
||||
SupportTicketSummary, WsMessage,
|
||||
};
|
||||
|
||||
const SHAPER_VERSION_STRING: &str = include_str!("../../../../VERSION_STRING");
|
||||
|
||||
fn current_shaper_version() -> Option<String> {
|
||||
let version = SHAPER_VERSION_STRING.trim();
|
||||
(!version.is_empty()).then(|| version.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HistoryQueryResultPayload {
|
||||
pub tag: String,
|
||||
@@ -1064,6 +1071,7 @@ async fn persistent_connection(
|
||||
license,
|
||||
node_id: config.node_id.clone(),
|
||||
node_name: config.node_name.clone(),
|
||||
shaper_version: current_shaper_version(),
|
||||
};
|
||||
let Ok((_, _, bytes)) = message.to_bytes() else {
|
||||
error!("Failed to serialize license message");
|
||||
@@ -1204,6 +1212,7 @@ async fn send_license(
|
||||
license,
|
||||
node_id: config.node_id.clone(),
|
||||
node_name: config.node_name.clone(),
|
||||
shaper_version: current_shaper_version(),
|
||||
};
|
||||
let (_, _, bytes) = message.to_bytes()?;
|
||||
timeout(
|
||||
|
||||
@@ -64,7 +64,7 @@ pub struct SupportTicket {
|
||||
|
||||
pub const MAX_DECOMPRESSED_WS_MSG_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum WsMessage {
|
||||
// Messages FROM the Shaper
|
||||
Hello {
|
||||
@@ -74,6 +74,8 @@ pub enum WsMessage {
|
||||
license: Uuid,
|
||||
node_id: String,
|
||||
node_name: String,
|
||||
#[serde(default)]
|
||||
shaper_version: Option<String>,
|
||||
},
|
||||
LicenseGrantRequest {
|
||||
public_key: Vec<u8>,
|
||||
@@ -254,7 +256,7 @@ pub enum WsMessage {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum ApiRequestType {
|
||||
Get,
|
||||
Post,
|
||||
@@ -277,3 +279,69 @@ impl WsMessage {
|
||||
Ok(serde_cbor::from_slice(&decompressed_bytes)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WsMessage;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
enum LegacyWsMessage {
|
||||
License {
|
||||
license: Uuid,
|
||||
node_id: String,
|
||||
node_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_round_trips_with_version() {
|
||||
let message = WsMessage::License {
|
||||
license: Uuid::nil(),
|
||||
node_id: "node-1".to_string(),
|
||||
node_name: "Node 1".to_string(),
|
||||
shaper_version: Some("2.1.0".to_string()),
|
||||
};
|
||||
let (_, _, bytes) = message.to_bytes().unwrap();
|
||||
|
||||
match WsMessage::from_bytes(&bytes).unwrap() {
|
||||
WsMessage::License {
|
||||
shaper_version,
|
||||
node_id,
|
||||
node_name,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(node_id, "node-1");
|
||||
assert_eq!(node_name, "Node 1");
|
||||
assert_eq!(shaper_version.as_deref(), Some("2.1.0"));
|
||||
}
|
||||
other => panic!("unexpected decoded message: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_license_payload_without_version_still_decodes() {
|
||||
let legacy = LegacyWsMessage::License {
|
||||
license: Uuid::nil(),
|
||||
node_id: "legacy-node".to_string(),
|
||||
node_name: "Legacy Node".to_string(),
|
||||
};
|
||||
let raw_bytes = serde_cbor::to_vec(&legacy).unwrap();
|
||||
let bytes = miniz_oxide::deflate::compress_to_vec(&raw_bytes, 1);
|
||||
|
||||
match WsMessage::from_bytes(&bytes).unwrap() {
|
||||
WsMessage::License {
|
||||
shaper_version,
|
||||
node_id,
|
||||
node_name,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(node_id, "legacy-node");
|
||||
assert_eq!(node_name, "Legacy Node");
|
||||
assert_eq!(shaper_version, None);
|
||||
}
|
||||
other => panic!("unexpected decoded message: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
-10
@@ -12,7 +12,6 @@ pub mod lts2_sys;
|
||||
mod node_manager;
|
||||
mod preflight_checks;
|
||||
mod program_control;
|
||||
mod reload_lock;
|
||||
mod remote_commands;
|
||||
mod rtt_exclusions;
|
||||
mod scheduler_control;
|
||||
@@ -38,10 +37,11 @@ use crate::{
|
||||
ip_mapping::{clear_ip_flows, del_ip_flow, list_mapped_ips, map_ip_to_flow},
|
||||
throughput_tracker::flow_data::{FlowActor, flowbee_handle_events, setup_netflow_tracker},
|
||||
};
|
||||
#[cfg(feature = "flamegraphs")]
|
||||
use allocative::Allocative;
|
||||
use anyhow::Result;
|
||||
use lqos_bus::{BusRequest, BusResponse, InsightLicenseSummary, UnixSocketServer};
|
||||
use lqos_bus::{
|
||||
BusRequest, BusResponse, InsightLicenseSummary, TreeGuardRuntimeNodeBranchSnapshot,
|
||||
TreeGuardRuntimeNodeOperationSnapshot, UnixSocketServer,
|
||||
};
|
||||
use lqos_heimdall::{n_second_packet_dump, perf_interface::heimdall_handle_events, start_heimdall};
|
||||
use lqos_queue_tracker::{
|
||||
add_watched_queue, get_raw_circuit_data, spawn_queue_monitor, spawn_queue_structure_monitor,
|
||||
@@ -66,12 +66,6 @@ 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;
|
||||
#[cfg(feature = "flamegraphs")]
|
||||
use crate::shaped_devices_tracker::NETWORK_JSON;
|
||||
#[cfg(feature = "flamegraphs")]
|
||||
use crate::throughput_tracker::THROUGHPUT_TRACKER;
|
||||
#[cfg(feature = "flamegraphs")]
|
||||
use crate::throughput_tracker::flow_data::{ALL_FLOWS, RECENT_FLOWS};
|
||||
use lqos_stormguard::{STORMGUARD_DEBUG, STORMGUARD_STATS};
|
||||
use tracing::level_filters::LevelFilter;
|
||||
// Use MiMalloc only on supported platforms
|
||||
@@ -515,6 +509,10 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
}
|
||||
BusResponse::Ack
|
||||
}
|
||||
BusRequest::InvalidateAuthCache => {
|
||||
crate::node_manager::invalidate_auth_cache();
|
||||
BusResponse::Ack
|
||||
}
|
||||
#[cfg(feature = "equinix_tests")]
|
||||
BusRequest::RequestLqosEquinixTest => lqos_daht_test::lqos_daht_test(),
|
||||
BusRequest::ValidateShapedDevicesCsv => validation::validate_shaped_devices_csv(),
|
||||
@@ -676,6 +674,8 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
}
|
||||
BusRequest::BakeryAddCircuit {
|
||||
circuit_hash,
|
||||
circuit_name,
|
||||
site_name,
|
||||
parent_class_id,
|
||||
up_parent_class_id,
|
||||
class_minor,
|
||||
@@ -702,6 +702,8 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
let sender = sender.clone();
|
||||
let _ = sender.send(lqos_bakery::BakeryCommands::AddCircuit {
|
||||
circuit_hash: *circuit_hash,
|
||||
circuit_name: circuit_name.clone(),
|
||||
site_name: site_name.clone(),
|
||||
parent_class_id: *parent_class_id,
|
||||
up_parent_class_id: *up_parent_class_id,
|
||||
class_minor: *class_minor,
|
||||
@@ -711,6 +713,8 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
upload_bandwidth_max: *upload_bandwidth_max,
|
||||
class_major: *class_major,
|
||||
up_class_major: *up_class_major,
|
||||
down_qdisc_handle: None,
|
||||
up_qdisc_handle: None,
|
||||
ip_addresses: ip_addresses.clone(),
|
||||
sqm_override: sqm_override.clone(),
|
||||
});
|
||||
@@ -736,6 +740,126 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
BusRequest::GetBakeryStats => BusResponse::BakeryActiveCircuits(
|
||||
lqos_bakery::ACTIVE_CIRCUITS.load(std::sync::atomic::Ordering::Relaxed),
|
||||
),
|
||||
BusRequest::BakeryReportPreflight {
|
||||
ok,
|
||||
message,
|
||||
safe_budget,
|
||||
hard_limit,
|
||||
estimated_total_memory_bytes,
|
||||
memory_available_bytes,
|
||||
memory_guard_min_available_bytes,
|
||||
memory_ok,
|
||||
interfaces,
|
||||
} => {
|
||||
lqos_bakery::record_qdisc_preflight_snapshot(
|
||||
lqos_bakery::BakeryPreflightSnapshot {
|
||||
ok: *ok,
|
||||
message: message.clone(),
|
||||
safe_budget: *safe_budget,
|
||||
hard_limit: *hard_limit,
|
||||
estimated_total_memory_bytes: *estimated_total_memory_bytes,
|
||||
memory_available_bytes: *memory_available_bytes,
|
||||
memory_guard_min_available_bytes: *memory_guard_min_available_bytes,
|
||||
memory_ok: *memory_ok,
|
||||
interfaces: interfaces
|
||||
.iter()
|
||||
.map(|entry| lqos_bakery::BakeryCapacityInterfaceSnapshot {
|
||||
name: entry.name.clone(),
|
||||
planned_qdiscs: entry.planned_qdiscs,
|
||||
infra_qdiscs: entry.infra_qdiscs,
|
||||
cake_qdiscs: entry.cake_qdiscs,
|
||||
fq_codel_qdiscs: entry.fq_codel_qdiscs,
|
||||
estimated_memory_bytes: entry.estimated_memory_bytes,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
BusResponse::Ack
|
||||
},
|
||||
BusRequest::TreeGuardSetNodeVirtual {
|
||||
node_name,
|
||||
virtualized,
|
||||
} => match crate::treeguard::bakery::submit_node_virtualization_live(
|
||||
node_name,
|
||||
*virtualized,
|
||||
) {
|
||||
Ok(()) => BusResponse::Ack,
|
||||
Err(err) => BusResponse::Fail(err.to_string()),
|
||||
},
|
||||
BusRequest::TreeGuardGetNodeVirtualStatus { node_name } => {
|
||||
let snapshot = crate::treeguard::bakery::node_virtualization_operation_status(
|
||||
node_name,
|
||||
)
|
||||
.map(|snapshot| {
|
||||
let failure_reason = snapshot.failure_reason.map(|reason| match reason {
|
||||
lqos_bakery::BakeryRuntimeNodeOperationFailureReason::StructuralIneligibleNoPromotableChildren => {
|
||||
"structural_ineligible_no_promotable_children".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationFailureReason::StructuralIneligibleSinglePromotableChild => {
|
||||
"structural_ineligible_single_promotable_child".to_string()
|
||||
}
|
||||
});
|
||||
TreeGuardRuntimeNodeOperationSnapshot {
|
||||
operation_id: snapshot.operation_id,
|
||||
site_hash: snapshot.site_hash,
|
||||
action: match snapshot.action {
|
||||
lqos_bakery::BakeryRuntimeNodeOperationAction::Virtualize => {
|
||||
"virtualize".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationAction::Restore => {
|
||||
"restore".to_string()
|
||||
}
|
||||
},
|
||||
status: match snapshot.status {
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::Submitted => {
|
||||
"submitted".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::Deferred => {
|
||||
"deferred".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::Applying => {
|
||||
"applying".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::AppliedAwaitingCleanup => {
|
||||
"applied_awaiting_cleanup".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::Completed => {
|
||||
"completed".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::Failed => {
|
||||
"failed".to_string()
|
||||
}
|
||||
lqos_bakery::BakeryRuntimeNodeOperationStatus::Dirty => {
|
||||
"dirty".to_string()
|
||||
}
|
||||
},
|
||||
attempt_count: snapshot.attempt_count,
|
||||
submitted_at_unix: snapshot.submitted_at_unix,
|
||||
updated_at_unix: snapshot.updated_at_unix,
|
||||
next_retry_at_unix: snapshot.next_retry_at_unix,
|
||||
last_error: snapshot.last_error,
|
||||
failure_reason,
|
||||
}});
|
||||
BusResponse::TreeGuardRuntimeNodeOperation(snapshot)
|
||||
}
|
||||
BusRequest::TreeGuardGetNodeVirtualBranchState { node_name } => {
|
||||
let snapshot = crate::treeguard::bakery::node_virtualization_branch_state(
|
||||
node_name,
|
||||
)
|
||||
.map(|snapshot| TreeGuardRuntimeNodeBranchSnapshot {
|
||||
site_hash: snapshot.site_hash,
|
||||
active_branch: snapshot.active_branch.to_ascii_lowercase(),
|
||||
lifecycle: snapshot.lifecycle,
|
||||
pending_prune: snapshot.pending_prune,
|
||||
next_prune_attempt_unix: snapshot.next_prune_attempt_unix,
|
||||
active_site_hashes: snapshot.active_site_hashes,
|
||||
saved_site_hashes: snapshot.saved_site_hashes,
|
||||
prune_site_hashes: snapshot.prune_site_hashes,
|
||||
qdisc_down_major: snapshot.qdisc_down_major,
|
||||
qdisc_up_major: snapshot.qdisc_up_major,
|
||||
});
|
||||
BusResponse::TreeGuardRuntimeNodeBranch(snapshot)
|
||||
}
|
||||
BusRequest::ApiReady => {
|
||||
tool_status::api_seen();
|
||||
BusResponse::Ack
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod auth;
|
||||
mod local_api;
|
||||
pub(crate) mod local_api;
|
||||
mod run;
|
||||
mod shaper_queries_actor;
|
||||
mod static_pages;
|
||||
@@ -7,8 +7,10 @@ mod template;
|
||||
mod warnings;
|
||||
pub(crate) mod ws;
|
||||
|
||||
pub(crate) use auth::invalidate_auth_cache;
|
||||
pub use local_api::circuit_count::circuit_count_data;
|
||||
pub use local_api::device_counts::device_count;
|
||||
pub(crate) use local_api::executive_cache::invalidate_executive_cache_snapshot;
|
||||
pub use local_api::flow_explorer::{
|
||||
FlowTimeline, asn_list_data, country_list_data, country_timeline_data, flow_timeline_data,
|
||||
protocol_list_data, protocol_timeline_data,
|
||||
|
||||
@@ -1,26 +1,89 @@
|
||||
//! Provides authentication for the Node Manager.
|
||||
//! This is designed to be broadly compatible with the original
|
||||
//! cookie-based system but now uses an Axum layer to be largely
|
||||
//! invisible.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, Response};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use lqos_config::{UserRole, WebUsers, load_config};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use hmac::{Hmac, Mac};
|
||||
use lqos_config::{AuthenticatedUser, UserRole, WebUsers, load_config};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use sha2::Sha256;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::warn;
|
||||
use tracing::{error, warn};
|
||||
|
||||
const COOKIE_PATH: &str = "User-Token";
|
||||
const COOKIE_NAME: &str = "User-Token";
|
||||
const SESSION_TOKEN_VERSION: &str = "v1";
|
||||
const SESSION_DURATION_SECS: u64 = 60 * 60 * 24 * 30;
|
||||
const SESSION_KEY_FILE_NAME: &str = "lqusers.session.key";
|
||||
|
||||
static WEB_USERS: Lazy<Mutex<Option<WebUsers>>> = Lazy::new(|| Mutex::new(None));
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct AuthFileFingerprint {
|
||||
path: PathBuf,
|
||||
modified_unix_secs: Option<u64>,
|
||||
modified_subsec_nanos: Option<u32>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CachedAuthSnapshot {
|
||||
fingerprint: Option<AuthFileFingerprint>,
|
||||
snapshot: AuthSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState,
|
||||
auth_epoch: u64,
|
||||
allow_anonymous: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AuthBootstrapState {
|
||||
MissingUsersFile,
|
||||
NoUsersConfigured,
|
||||
Ready,
|
||||
CorruptUsersFile,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct SessionClaims {
|
||||
sub: String,
|
||||
role: UserRole,
|
||||
auth_epoch: u64,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SessionUser {
|
||||
username: String,
|
||||
role: UserRole,
|
||||
}
|
||||
|
||||
static AUTH_SNAPSHOT: Lazy<Mutex<Option<CachedAuthSnapshot>>> = Lazy::new(|| Mutex::new(None));
|
||||
static SESSION_KEY: Lazy<Mutex<Option<Vec<u8>>>> = Lazy::new(|| Mutex::new(None));
|
||||
pub static FIRST_LOAD: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn record_first_login_timestamp_if_needed() {
|
||||
@@ -60,15 +123,291 @@ fn record_first_login_timestamp_if_needed() {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_username(jar: &CookieJar) -> String {
|
||||
let lock = WEB_USERS.lock().await;
|
||||
if let Some(users) = &*lock
|
||||
&& let Some(token) = jar.get(COOKIE_PATH)
|
||||
fn now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn auth_file_fingerprint(path: &Path) -> Result<AuthFileFingerprint, std::io::Error> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok());
|
||||
|
||||
Ok(AuthFileFingerprint {
|
||||
path: path.to_path_buf(),
|
||||
modified_unix_secs: modified.map(|d| d.as_secs()),
|
||||
modified_subsec_nanos: modified.map(|d| d.subsec_nanos()),
|
||||
len: metadata.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_snapshot() -> AuthSnapshot {
|
||||
let current_fingerprint = match WebUsers::existing_path() {
|
||||
Ok(Some(path)) => match auth_file_fingerprint(&path) {
|
||||
Ok(fingerprint) => Some(fingerprint),
|
||||
Err(e) => {
|
||||
warn!("Unable to stat auth file {:?}: {e}", path);
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("Unable to resolve auth file path: {e}");
|
||||
return AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState::CorruptUsersFile,
|
||||
auth_epoch: 0,
|
||||
allow_anonymous: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut cache = AUTH_SNAPSHOT.lock();
|
||||
if let Some(cached) = &*cache
|
||||
&& cached.fingerprint == current_fingerprint
|
||||
{
|
||||
return users.get_username(token.value());
|
||||
return cached.snapshot.clone();
|
||||
}
|
||||
|
||||
"Anonymous".to_string()
|
||||
let snapshot = match current_fingerprint {
|
||||
None => AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState::MissingUsersFile,
|
||||
auth_epoch: 0,
|
||||
allow_anonymous: false,
|
||||
},
|
||||
Some(_) => match WebUsers::load_or_create() {
|
||||
Ok(users) => AuthSnapshot {
|
||||
bootstrap_state: if users.is_empty() {
|
||||
AuthBootstrapState::NoUsersConfigured
|
||||
} else {
|
||||
AuthBootstrapState::Ready
|
||||
},
|
||||
auth_epoch: users.auth_epoch(),
|
||||
allow_anonymous: users.do_we_allow_anonymous(),
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Unable to load auth state: {e}");
|
||||
AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState::CorruptUsersFile,
|
||||
auth_epoch: 0,
|
||||
allow_anonymous: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let refreshed_fingerprint = match WebUsers::existing_path() {
|
||||
Ok(Some(path)) => auth_file_fingerprint(&path).ok(),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("Unable to refresh auth file path after load: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
*cache = Some(CachedAuthSnapshot {
|
||||
fingerprint: refreshed_fingerprint,
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn session_key_path() -> Result<PathBuf, std::io::Error> {
|
||||
let config = load_config().map_err(|_| {
|
||||
std::io::Error::other("Unable to load /etc/lqos.conf while locating session key")
|
||||
})?;
|
||||
Ok(Path::new(&config.lqos_directory).join(SESSION_KEY_FILE_NAME))
|
||||
}
|
||||
|
||||
fn session_key() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut cache = SESSION_KEY.lock();
|
||||
if let Some(key) = &*cache {
|
||||
return Ok(key.clone());
|
||||
}
|
||||
|
||||
let path = session_key_path()?;
|
||||
let key = if path.exists() {
|
||||
let bytes = std::fs::read(&path)?;
|
||||
if bytes.is_empty() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Session key file {:?} is empty",
|
||||
path
|
||||
)));
|
||||
}
|
||||
bytes
|
||||
} else {
|
||||
let mut new_key = vec![0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut new_key);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
file.write_all(&new_key)?;
|
||||
drop(file);
|
||||
new_key
|
||||
};
|
||||
|
||||
*cache = Some(key.clone());
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn build_session_cookie(token: String) -> Cookie<'static> {
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token);
|
||||
cookie.set_path("/");
|
||||
cookie.set_same_site(SameSite::Lax);
|
||||
cookie
|
||||
}
|
||||
|
||||
fn build_signed_session(key: &[u8], user: &AuthenticatedUser) -> Result<String, StatusCode> {
|
||||
let now = now_unix_secs();
|
||||
let claims = SessionClaims {
|
||||
sub: user.username.clone(),
|
||||
role: user.role,
|
||||
auth_epoch: user.auth_epoch,
|
||||
iat: now,
|
||||
exp: now.saturating_add(SESSION_DURATION_SECS),
|
||||
};
|
||||
let payload = serde_json::to_vec(&claims).map_err(|e| {
|
||||
error!("Unable to serialize session claims: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(key).map_err(|e| {
|
||||
error!("Unable to initialize session signer: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
Ok(format!("{SESSION_TOKEN_VERSION}.{payload_b64}.{signature}"))
|
||||
}
|
||||
|
||||
fn verify_signed_session(
|
||||
key: &[u8],
|
||||
token: &str,
|
||||
snapshot: &AuthSnapshot,
|
||||
) -> Result<Option<SessionUser>, StatusCode> {
|
||||
let Some((version, remainder)) = token.split_once('.') else {
|
||||
return Ok(None);
|
||||
};
|
||||
if version != SESSION_TOKEN_VERSION {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((payload_b64, signature_b64)) = remainder.rsplit_once('.') else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(key).map_err(|e| {
|
||||
error!("Unable to initialize session verifier: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
if mac.verify_slice(&signature).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let payload = URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
let claims: SessionClaims =
|
||||
serde_json::from_slice(&payload).map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let now = now_unix_secs();
|
||||
if claims.exp <= now || claims.auth_epoch != snapshot.auth_epoch {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(SessionUser {
|
||||
username: claims.sub,
|
||||
role: claims.role,
|
||||
}))
|
||||
}
|
||||
|
||||
fn session_from_cookie(
|
||||
jar: &CookieJar,
|
||||
snapshot: &AuthSnapshot,
|
||||
) -> Result<Option<SessionUser>, StatusCode> {
|
||||
let Some(token) = jar.get(COOKIE_NAME) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let key = session_key().map_err(|e| {
|
||||
error!("Unable to load session key: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
verify_signed_session(&key, token.value(), snapshot)
|
||||
}
|
||||
|
||||
fn standalone_page_path(page: &str) -> Result<PathBuf, StatusCode> {
|
||||
let config = load_config().map_err(|e| {
|
||||
error!("Unable to load config for standalone page {page}: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(Path::new(&config.lqos_directory)
|
||||
.join("bin")
|
||||
.join("static2")
|
||||
.join(page))
|
||||
}
|
||||
|
||||
fn serve_standalone_page(page: &str) -> Result<Response, StatusCode> {
|
||||
let path = standalone_page_path(page)?;
|
||||
let body = std::fs::read_to_string(&path).map_err(|e| {
|
||||
error!("Unable to read standalone page {:?}: {e}", path);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(Html(body).into_response())
|
||||
}
|
||||
|
||||
pub async fn login_page(jar: CookieJar) -> Response {
|
||||
let snapshot = auth_snapshot();
|
||||
match snapshot.bootstrap_state {
|
||||
AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => {
|
||||
Redirect::temporary("/first-run.html").into_response()
|
||||
}
|
||||
AuthBootstrapState::Ready => match session_from_cookie(&jar, &snapshot) {
|
||||
Ok(Some(_)) => Redirect::temporary("/index.html").into_response(),
|
||||
Ok(None) => serve_standalone_page("login.html")
|
||||
.unwrap_or_else(|status| (status, "Unable to serve login page").into_response()),
|
||||
Err(status) => (status, "Unable to validate login session").into_response(),
|
||||
},
|
||||
AuthBootstrapState::CorruptUsersFile => serve_standalone_page("login.html")
|
||||
.unwrap_or_else(|status| (status, "Unable to serve login page").into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn first_run_page() -> Response {
|
||||
let snapshot = auth_snapshot();
|
||||
match snapshot.bootstrap_state {
|
||||
AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => {
|
||||
serve_standalone_page("first-run.html")
|
||||
.unwrap_or_else(|status| (status, "Unable to serve first-run page").into_response())
|
||||
}
|
||||
AuthBootstrapState::Ready | AuthBootstrapState::CorruptUsersFile => {
|
||||
Redirect::temporary("/index.html").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_username(jar: &CookieJar) -> String {
|
||||
let snapshot = auth_snapshot();
|
||||
match session_from_cookie(jar, &snapshot) {
|
||||
Ok(Some(user)) => user.username,
|
||||
Ok(None) | Err(_) => "Anonymous".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
@@ -78,100 +417,76 @@ pub enum LoginResult {
|
||||
Denied,
|
||||
}
|
||||
|
||||
async fn check_login(jar: &CookieJar, users: &WebUsers) -> LoginResult {
|
||||
if let Some(token) = jar.get(COOKIE_PATH) {
|
||||
// Validate the token
|
||||
return match users.get_role_from_token(token.value()) {
|
||||
Ok(UserRole::ReadOnly) => LoginResult::ReadOnly,
|
||||
Ok(UserRole::Admin) => LoginResult::Admin,
|
||||
Err(_e) => LoginResult::Denied,
|
||||
};
|
||||
fn login_result_for_session(user: Option<SessionUser>, allow_anonymous: bool) -> LoginResult {
|
||||
match user {
|
||||
Some(SessionUser {
|
||||
role: UserRole::Admin,
|
||||
..
|
||||
}) => LoginResult::Admin,
|
||||
Some(SessionUser {
|
||||
role: UserRole::ReadOnly,
|
||||
..
|
||||
}) => LoginResult::ReadOnly,
|
||||
None if allow_anonymous => LoginResult::ReadOnly,
|
||||
None => LoginResult::Denied,
|
||||
}
|
||||
LoginResult::Denied
|
||||
}
|
||||
|
||||
/// Checks an incoming request for a User-Token cookie. If found,
|
||||
/// it validates the request against the web users file. If the
|
||||
/// web users file isn't found, it redirects to 'first run'. If
|
||||
/// it is found, the token is checked (and redirected to login if
|
||||
/// it isn't good). Finally, the user's role is injected into
|
||||
/// the middleware.
|
||||
/// Checks an incoming request for a `User-Token` cookie. If found,
|
||||
/// it validates the request against the signed session and current auth epoch.
|
||||
/// Missing or empty auth state redirects to first-run; invalid sessions redirect
|
||||
/// to login unless anonymous read-only is enabled.
|
||||
pub async fn auth_layer(
|
||||
jar: CookieJar,
|
||||
mut req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<Response, Html<&'static str>> {
|
||||
const BOUNCE: &str =
|
||||
"<html><body><script>window.location.href = 'login.html';</script></body></html>";
|
||||
const FIRST_RUN: &str =
|
||||
"<html><body><script>window.location.href = 'first-run.html';</script></body></html>";
|
||||
|
||||
let mut lock = WEB_USERS.lock().await;
|
||||
if lock.is_none() {
|
||||
// No lock - let's see if there's a file to use?
|
||||
if WebUsers::does_users_file_exist().expect("Error checking if the users file exists") {
|
||||
// It exists - we load it
|
||||
let users = WebUsers::load_or_create().expect("Unable to load users file");
|
||||
*lock = Some(users);
|
||||
} else {
|
||||
// No users file - redirect to first run
|
||||
return Err(Html(FIRST_RUN));
|
||||
) -> Response {
|
||||
let snapshot = auth_snapshot();
|
||||
match snapshot.bootstrap_state {
|
||||
AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => {
|
||||
return Redirect::temporary("/first-run.html").into_response();
|
||||
}
|
||||
AuthBootstrapState::CorruptUsersFile => {
|
||||
return Redirect::temporary("/login.html").into_response();
|
||||
}
|
||||
AuthBootstrapState::Ready => {}
|
||||
}
|
||||
|
||||
if let Some(users) = &*lock {
|
||||
let login_result = check_login(&jar, users).await;
|
||||
return match login_result {
|
||||
LoginResult::Admin | LoginResult::ReadOnly => {
|
||||
record_first_login_timestamp_if_needed();
|
||||
req.extensions_mut().insert(login_result);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
LoginResult::Denied => {
|
||||
let users = WebUsers::load_or_create().expect("Could not load users file");
|
||||
if users.do_we_allow_anonymous() {
|
||||
req.extensions_mut().insert(LoginResult::ReadOnly);
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
Err(Html(BOUNCE))
|
||||
}
|
||||
}
|
||||
};
|
||||
let login_result = match session_from_cookie(&jar, &snapshot) {
|
||||
Ok(user) => login_result_for_session(user, snapshot.allow_anonymous),
|
||||
Err(status) => return (status, "Unable to validate session").into_response(),
|
||||
};
|
||||
|
||||
match login_result {
|
||||
LoginResult::Admin | LoginResult::ReadOnly => {
|
||||
record_first_login_timestamp_if_needed();
|
||||
req.extensions_mut().insert(login_result);
|
||||
next.run(req).await
|
||||
}
|
||||
LoginResult::Denied => Redirect::temporary("/login.html").into_response(),
|
||||
}
|
||||
Err(Html(BOUNCE))
|
||||
}
|
||||
|
||||
pub async fn login_from_token(token: &str) -> LoginResult {
|
||||
let mut lock = WEB_USERS.lock().await;
|
||||
if lock.is_none() {
|
||||
match WebUsers::does_users_file_exist() {
|
||||
Ok(true) => match WebUsers::load_or_create() {
|
||||
Ok(users) => {
|
||||
*lock = Some(users);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Unable to load users file for websocket auth: {e}");
|
||||
return LoginResult::Denied;
|
||||
}
|
||||
},
|
||||
Ok(false) => {
|
||||
return LoginResult::Denied;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Unable to check users file for websocket auth: {e}");
|
||||
return LoginResult::Denied;
|
||||
}
|
||||
}
|
||||
let snapshot = auth_snapshot();
|
||||
if snapshot.bootstrap_state != AuthBootstrapState::Ready {
|
||||
return LoginResult::Denied;
|
||||
}
|
||||
|
||||
let Some(users) = &*lock else {
|
||||
return LoginResult::Denied;
|
||||
let key = match session_key() {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
warn!("Unable to load session key for websocket auth: {e}");
|
||||
return LoginResult::Denied;
|
||||
}
|
||||
};
|
||||
|
||||
let login_result = match users.get_role_from_token(token) {
|
||||
Ok(UserRole::ReadOnly) => LoginResult::ReadOnly,
|
||||
Ok(UserRole::Admin) => LoginResult::Admin,
|
||||
Err(_) => LoginResult::Denied,
|
||||
let login_result = match verify_signed_session(&key, token, &snapshot) {
|
||||
Ok(user) => login_result_for_session(user, snapshot.allow_anonymous),
|
||||
Err(e) => {
|
||||
warn!("Unable to verify websocket session token: {e}");
|
||||
LoginResult::Denied
|
||||
}
|
||||
};
|
||||
|
||||
if login_result != LoginResult::Denied {
|
||||
@@ -181,25 +496,15 @@ pub async fn login_from_token(token: &str) -> LoginResult {
|
||||
login_result
|
||||
}
|
||||
|
||||
/// Reload the cached users from disk after user-management changes.
|
||||
/// Invalidate the cached auth snapshot after user-management changes.
|
||||
pub fn invalidate_auth_cache() {
|
||||
let mut lock = AUTH_SNAPSHOT.lock();
|
||||
*lock = None;
|
||||
}
|
||||
|
||||
/// Reload the cached auth snapshot after user-management changes.
|
||||
pub async fn refresh_cached_users() {
|
||||
let mut lock = WEB_USERS.lock().await;
|
||||
match WebUsers::does_users_file_exist() {
|
||||
Ok(true) => match WebUsers::load_or_create() {
|
||||
Ok(users) => {
|
||||
*lock = Some(users);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Unable to refresh users cache: {e}");
|
||||
}
|
||||
},
|
||||
Ok(false) => {
|
||||
*lock = None;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Unable to check users file while refreshing cache: {e}");
|
||||
}
|
||||
}
|
||||
invalidate_auth_cache();
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -211,25 +516,88 @@ pub struct LoginAttempt {
|
||||
pub async fn try_login(
|
||||
jar: CookieJar,
|
||||
Json(login): Json<LoginAttempt>,
|
||||
) -> Result<(CookieJar, StatusCode), StatusCode> {
|
||||
let users = WEB_USERS.lock().await;
|
||||
if let Some(users) = &*users {
|
||||
return match users.login(&login.username, &login.password) {
|
||||
Ok(token) => {
|
||||
record_first_login_timestamp_if_needed();
|
||||
Ok((jar.add(Cookie::new(COOKIE_PATH, token)), StatusCode::OK))
|
||||
}
|
||||
Err(..) => {
|
||||
if users.do_we_allow_anonymous() {
|
||||
record_first_login_timestamp_if_needed();
|
||||
Ok((jar, StatusCode::OK))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
};
|
||||
) -> Result<(CookieJar, Json<LoginResponse>), (StatusCode, Json<LoginResponse>)> {
|
||||
let snapshot = auth_snapshot();
|
||||
match snapshot.bootstrap_state {
|
||||
AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("first_run_required"),
|
||||
message: Some("No users are configured yet.".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
AuthBootstrapState::CorruptUsersFile => {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("The auth file is corrupt and must be repaired.".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
AuthBootstrapState::Ready => {}
|
||||
}
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
|
||||
let mut users = WebUsers::load_or_create().map_err(|e| {
|
||||
warn!("Unable to load users during login: {e}");
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("The auth file is corrupt and must be repaired.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let authenticated = users
|
||||
.authenticate(&login.username, &login.password)
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("invalid_credentials"),
|
||||
message: Some("Invalid username or password.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
invalidate_auth_cache();
|
||||
let key = session_key().map_err(|e| {
|
||||
error!("Unable to load session key during login: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("session_error"),
|
||||
message: Some("Unable to create session token.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let token = build_signed_session(&key, &authenticated).map_err(|status| {
|
||||
(
|
||||
status,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("session_error"),
|
||||
message: Some("Unable to create session token.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
record_first_login_timestamp_if_needed();
|
||||
Ok((
|
||||
jar.add(build_session_cookie(token)),
|
||||
Json(LoginResponse {
|
||||
ok: true,
|
||||
reason: None,
|
||||
message: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -242,16 +610,106 @@ pub struct FirstUser {
|
||||
pub async fn first_user(
|
||||
jar: CookieJar,
|
||||
Json(new_user): Json<FirstUser>,
|
||||
) -> (CookieJar, StatusCode) {
|
||||
let mut users = WebUsers::load_or_create().expect("Could not load users file");
|
||||
) -> Result<(CookieJar, Json<LoginResponse>), (StatusCode, Json<LoginResponse>)> {
|
||||
let snapshot = auth_snapshot();
|
||||
match snapshot.bootstrap_state {
|
||||
AuthBootstrapState::Ready => {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("already_configured"),
|
||||
message: Some("Web authentication is already configured.".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
AuthBootstrapState::CorruptUsersFile => {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("The auth file is corrupt and must be repaired.".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => {}
|
||||
}
|
||||
|
||||
let mut users = WebUsers::load_or_create().map_err(|e| {
|
||||
warn!("Unable to load users during first-run setup: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("Unable to initialize auth storage.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
users
|
||||
.allow_anonymous(new_user.allow_anonymous)
|
||||
.expect("Unable to set property");
|
||||
let token = users
|
||||
.map_err(|e| {
|
||||
warn!("Unable to set anonymous auth policy: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("Unable to update auth settings.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
users
|
||||
.add_or_update_user(&new_user.username, &new_user.password, UserRole::Admin)
|
||||
.expect("Unable to add or update user");
|
||||
let mut lock = WEB_USERS.lock().await;
|
||||
*lock = Some(users);
|
||||
.map_err(|e| {
|
||||
warn!("Unable to create first user: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("Unable to create the first user.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
invalidate_auth_cache();
|
||||
let authenticated = AuthenticatedUser {
|
||||
username: new_user.username,
|
||||
role: UserRole::Admin,
|
||||
auth_epoch: users.auth_epoch(),
|
||||
password_upgraded: false,
|
||||
};
|
||||
let key = session_key().map_err(|e| {
|
||||
error!("Unable to load session key during first-run setup: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("session_error"),
|
||||
message: Some("Unable to create session token.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let token = build_signed_session(&key, &authenticated).map_err(|status| {
|
||||
(
|
||||
status,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("session_error"),
|
||||
message: Some("Unable to create session token.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
record_first_login_timestamp_if_needed();
|
||||
(jar.add(Cookie::new(COOKIE_PATH, token)), StatusCode::OK)
|
||||
Ok((
|
||||
jar.add(build_session_cookie(token)),
|
||||
Json(LoginResponse {
|
||||
ok: true,
|
||||
reason: None,
|
||||
message: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user