From 85624c7465fbba753b82e1da64d790b0f6517a3a Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Thu, 16 Apr 2026 11:06:06 -0500 Subject: [PATCH] Add optional Caddy HTTPS setup --- docs-es/v2.0/api-es.md | 5 +- docs-es/v2.0/components-es.md | 6 +- docs-es/v2.0/configuration-es.md | 6 +- docs-es/v2.0/git-install-es.md | 2 + docs-es/v2.0/https-caddy-es.md | 51 ++ docs-es/v2.0/node-manager-ui-es.md | 4 +- docs-es/v2.0/quickstart-es.md | 3 + docs-es/v2.0/troubleshooting-es.md | 17 +- docs/v2.0/api.md | 5 +- docs/v2.0/components.md | 6 +- docs/v2.0/configuration.md | 6 +- docs/v2.0/git-install.md | 2 + docs/v2.0/https-caddy.md | 51 ++ docs/v2.0/node-manager-ui.md | 4 +- docs/v2.0/quickstart.md | 3 + docs/v2.0/troubleshooting.md | 17 +- src/build_dpkg.sh | 10 +- src/disable_caddy.sh | 16 + src/install_caddy.sh | 28 + src/rust/Cargo.lock | 2 + src/rust/lqos_config/src/etc/mod.rs | 5 +- src/rust/lqos_config/src/etc/v15/example.toml | 5 + src/rust/lqos_config/src/etc/v15/mod.rs | 1 + .../lqos_config/src/etc/v15/top_config.rs | 111 ++- src/rust/lqos_config/src/lib.rs | 6 +- src/rust/lqos_setup/Cargo.toml | 1 + src/rust/lqos_setup/src/lib.rs | 1 + src/rust/lqos_setup/src/ssl.rs | 718 ++++++++++++++++++ src/rust/lqos_setup/src/web.rs | 198 ++++- src/rust/lqosd/Cargo.toml | 1 + .../src/node_manager/js_build/entrypoints.txt | 1 + .../js_build/src/config/config_helper.js | 11 + .../js_build/src/config_general.js | 17 +- .../node_manager/js_build/src/config_ssl.js | 151 ++++ .../src/node_manager/js_build/src/template.js | 19 +- src/rust/lqosd/src/node_manager/local_api.rs | 4 + .../lqosd/src/node_manager/local_api/ssl.rs | 55 ++ .../lqosd/src/node_manager/static2/api.html | 4 +- .../src/node_manager/static2/api_docs.html | 7 +- .../node_manager/static2/config_general.html | 1 + .../src/node_manager/static2/config_ssl.html | 88 +++ .../lqosd/src/node_manager/static_pages.rs | 1 + 42 files changed, 1583 insertions(+), 67 deletions(-) create mode 100644 docs-es/v2.0/https-caddy-es.md create mode 100644 docs/v2.0/https-caddy.md create mode 100755 src/disable_caddy.sh create mode 100755 src/install_caddy.sh create mode 100644 src/rust/lqos_setup/src/ssl.rs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/config_ssl.js create mode 100644 src/rust/lqosd/src/node_manager/local_api/ssl.rs create mode 100644 src/rust/lqosd/src/node_manager/static2/config_ssl.html diff --git a/docs-es/v2.0/api-es.md b/docs-es/v2.0/api-es.md index 09d68125..5019b63b 100644 --- a/docs-es/v2.0/api-es.md +++ b/docs-es/v2.0/api-es.md @@ -13,7 +13,8 @@ Esto es independiente de los límites base de shaping: Use Swagger en su nodo como referencia completa y superficie de prueba para la versión instalada: -- `http://:9122/api-docs` +- Puerto local directo de la API: `http://:9122/api-docs` +- Si la WebUI usa HTTPS opcional con Caddy: `https:///api/v1/api-docs` Use esta página como mapa de capacidades. Use Swagger para inventario completo de endpoints, esquemas request/response y pruebas en vivo. @@ -142,3 +143,5 @@ Si hay integraciones habilitadas, sus ciclos de refresco pueden sobrescribir edi - No exponga la API directamente a Internet público. - Si necesita acceso remoto, use reverse proxy con TLS y autenticación. - Restrinja acceso entrante con allowlists de firewall. + +Si quiere que la WebUI y la documentación de la API compartan el mismo origen HTTPS, vea [HTTPS opcional con Caddy](https-caddy-es.md). diff --git a/docs-es/v2.0/components-es.md b/docs-es/v2.0/components-es.md index 0eff075d..63257c1c 100644 --- a/docs-es/v2.0/components-es.md +++ b/docs-es/v2.0/components-es.md @@ -10,7 +10,8 @@ flowchart LR B --> E[Refresco del plan de colas/shaping] E --> F[lqosd] F --> G[Shaping runtime XDP/TC] - F --> H[WebUI :9123] + F --> H[Servicio WebUI :9123] + J[HTTPS opcional con Caddy :443] --> H B --> I[Estado del scheduler / problemas urgentes] F --> I ``` @@ -19,7 +20,8 @@ flowchart LR - Administra la lógica de shaping/XDP. - Implementado en Rust. -- Ejecuta la UI local disponible en `http://a.b.c.d:9123`. +- Ejecuta la UI local disponible en `http://a.b.c.d:9123` por defecto. +- Si HTTPS opcional está habilitado, Caddy publica `https://hostname/` o `https://ip-de-gestión/` y proxya la WebUI hacia `127.0.0.1:9123`. - Hospeda páginas de WebUI (Node Manager) como: - Site Map - Flow Globe diff --git a/docs-es/v2.0/configuration-es.md b/docs-es/v2.0/configuration-es.md index d64032ae..d384ea87 100644 --- a/docs-es/v2.0/configuration-es.md +++ b/docs-es/v2.0/configuration-es.md @@ -18,6 +18,8 @@ Después de instalar el paquete: 4. Abra `Complete Setup` 5. Elija cómo recibirá LibreQoS los datos de suscriptores y topología +Si quiere que los operadores usen HTTPS, abra `Configuration -> SSL Setup` después de iniciar sesión, o active la misma opción durante la configuración inicial. Vea [HTTPS opcional con Caddy](https-caddy-es.md). + Para la mayoría de los operadores, `Complete Setup` es donde ocurre la decisión importante del inicio: - Integración incluida para UISP, Splynx, VISP, Netzur, Powercode, Sonar o WispGate - Importador personalizado si su propio proceso escribe `network.json` y `ShapedDevices.csv` @@ -27,13 +29,14 @@ Si Scheduler Status todavía muestra `Setup Required`, LibreQoS aún no está li ## Configuración mediante la interfaz web -La mayoría de los cambios operativos diarios se hacen en la WebUI (`http://tu_ip_del_shaper:9123/config_general.html`). +La mayoría de los cambios operativos diarios se hacen en la WebUI. Por defecto eso es `http://tu_ip_del_shaper:9123/config_general.html`. Si habilita HTTPS opcional con Caddy, los operadores usarán `https://tu-hostname/` o `https://tu-ip-de-gestión/` en su lugar. Las versiones actuales usan una disposición coherente en las páginas General, RTT, Queues, TreeGuard, Network Mode, Integration Defaults, Network Layout, Insight, páginas de integración por proveedor, IP Ranges, Flow Tracking y Shaped Devices. Integration Defaults también incluye la política compartida de margen para puertos Ethernet usada por integraciones que pueden detectar la velocidad negociada hacia el suscriptor. ### Dónde en la WebUI - Ajustes generales: `Configuration -> General` +- Configuración de HTTPS: `Configuration -> SSL Setup` - Ajustes de integración: `Configuration -> Integrations` - Editor de topología: `Configuration -> Network Layout` - Editor de dispositivos regulados: `Configuration -> Shaped Devices` @@ -101,6 +104,7 @@ Para edición directa de archivos (`/etc/lqos.conf`, `network.json`, `ShapedDevi ## Páginas relacionadas - [Inicio rápido](quickstart-es.md) +- [HTTPS opcional con Caddy](https-caddy-es.md) - [Modos de operación y fuente de verdad](operating-modes-es.md) - [Integraciones CRM/NMS](integrations-es.md) - [LibreQoS WebUI (Node Manager)](node-manager-ui-es.md) diff --git a/docs-es/v2.0/git-install-es.md b/docs-es/v2.0/git-install-es.md index 9f155ef8..0e871140 100644 --- a/docs-es/v2.0/git-install-es.md +++ b/docs-es/v2.0/git-install-es.md @@ -94,3 +94,5 @@ sudo systemctl start lqosd lqos_scheduler ``` Ahora puede abrir un navegador web en `http://a.b.c.d:9123` (reemplace `a.b.c.d` con la dirección IP de administración de su servidor de shaping) y disfrutar de una vista en tiempo real de su red. + +Si quiere que los operadores usen HTTPS en su lugar, inicie sesión y abra `Configuration -> SSL Setup`. Vea [HTTPS opcional con Caddy](https-caddy-es.md). diff --git a/docs-es/v2.0/https-caddy-es.md b/docs-es/v2.0/https-caddy-es.md new file mode 100644 index 00000000..91ea5d5e --- /dev/null +++ b/docs-es/v2.0/https-caddy-es.md @@ -0,0 +1,51 @@ +# HTTPS opcional con Caddy + +LibreQoS puede publicar la WebUI y la documentación de la API detrás de Caddy para que los operadores usen HTTPS. Esto es opcional. LibreQoS sigue funcionando sin esta función. + +## Cuándo usarlo + +Use `Configuration -> SSL Setup` cuando quiera que los operadores abran LibreQoS con HTTPS en lugar de HTTP plano en el puerto `9123`. + +También puede habilitar la misma opción durante el flujo de configuración inicial. + +## Dos modos de certificado + +LibreQoS ofrece dos opciones simples: + +- Ingrese un hostname externo como `libreqos.example.com`: Caddy solicitará un certificado público a Let's Encrypt. Los navegadores deberían confiar en él automáticamente una vez que DNS y el acceso entrante estén correctos. +- Deje el hostname vacío: Caddy protegerá LibreQoS por dirección IP de gestión usando la autoridad certificadora local de Caddy. El tráfico sigue cifrado, pero las computadoras de los operadores deben confiar en el certificado raíz local de Caddy antes de que desaparezcan las advertencias del navegador. + +## Qué cambia después de habilitarlo + +- Los operadores dejan de usar `http://tu_ip_del_shaper:9123` y pasan a usar `https://tu-hostname/` o `https://tu-ip-de-gestión/`. +- LibreQoS mueve el listener de la WebUI a `127.0.0.1:9123`. +- Caddy publica la WebUI y la documentación de la API por HTTPS. +- Swagger pasa a `/api/v1/api-docs` en el mismo origen HTTPS de la WebUI. + +## Si usa el modo de certificado local + +Cuando deja el hostname vacío, el certificado raíz local de Caddy se guarda en el host LibreQoS en: + +```text +/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt +``` + +Confíe en ese certificado en cada estación de trabajo de operador que vaya a abrir la WebUI por HTTPS. + +## Deshabilitar HTTPS + +Si quiere revertir el cambio, abra `Configuration -> SSL Setup` y elija `Disable SSL`. + +LibreQoS entonces: + +- elimina la configuración gestionada de Caddy +- restaura el listener directo anterior de la WebUI, o el valor predeterminado normal `:::9123` si antes no había un listener personalizado +- devuelve a los operadores el acceso HTTP directo por la IP de gestión y el puerto `9123` + +## Páginas relacionadas + +- [Inicio rápido](quickstart-es.md) +- [Configurar LibreQoS](configuration-es.md) +- [Interfaz WebUI (Node Manager)](node-manager-ui-es.md) +- [API del nodo de LibreQoS](api-es.md) +- [Solución de problemas](troubleshooting-es.md) diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 230cc77a..8b9eb31c 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -1,6 +1,6 @@ # Interfaz WebUI (Node Manager) de LibreQoS -Esta página documenta las vistas clave de la WebUI (Node Manager) y su comportamiento operativo en la interfaz local (`http://ip_del_shaper:9123`). +Esta página documenta las vistas clave de la WebUI (Node Manager) y su comportamiento operativo en la interfaz local. Por defecto los operadores usan `http://ip_del_shaper:9123`. Si se habilita HTTPS opcional con Caddy, los operadores usan `https://tu-hostname/` o `https://tu-ip-de-gestión/`. ## Vistas principales @@ -122,6 +122,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - Las contraseñas, los tokens API y las claves guardadas permanecen ocultos al abrir una página de integración. - Si ya existe una credencial guardada, el campo permanece vacío hasta que escriba un reemplazo. Use `Clear` solo cuando quiera quitar el valor guardado. - Deje el campo vacío para conservar el valor actual. Escriba uno nuevo para reemplazarlo. +- `Configuration -> SSL Setup` puede instalar Caddy, poner la WebUI detrás de HTTPS y luego desactivar ese HTTPS si quiere volver al HTTP directo en el puerto `9123`. ### Problemas urgentes - WebUI puede mostrar problemas operativos urgentes informados por los servicios de LibreQoS. @@ -166,4 +167,5 @@ journalctl -u lqosd --since "10 minutes ago" - [Componentes](components-es.md) - [Configuración](configuration-es.md) +- [HTTPS opcional con Caddy](https-caddy-es.md) - [Solución de Problemas](troubleshooting-es.md) diff --git a/docs-es/v2.0/quickstart-es.md b/docs-es/v2.0/quickstart-es.md index 1df3cf1e..b302ed04 100644 --- a/docs-es/v2.0/quickstart-es.md +++ b/docs-es/v2.0/quickstart-es.md @@ -66,6 +66,8 @@ sudo apt install ./{deb_url_v1_5} 3. Cree el usuario administrador inicial si se lo pide. 4. Inicie sesión y confirme que carga el Dashboard. +Opcional: si los operadores necesitan HTTPS, habilite `Configuration -> SSL Setup` después de iniciar sesión, o active la misma opción durante la configuración inicial. Vea [HTTPS opcional con Caddy](https-caddy-es.md). + En este punto, que la WebUI responda no demuestra todavía que LibreQoS ya esté listo para hacer shaping de suscriptores. Si Scheduler Status muestra `Setup Required`, eso es normal hasta que elija una fuente de topología y publique datos válidos de shaping. @@ -177,6 +179,7 @@ Amplíe solo después de tener una base conocida y estable. ## 8) Páginas relacionadas - [Configurar LibreQoS](configuration-es.md) +- [HTTPS opcional con Caddy](https-caddy-es.md) - [Modos de operación y fuente de verdad](operating-modes-es.md) - [Integraciones CRM/NMS](integrations-es.md) - [Referencia avanzada de configuración](configuration-advanced-es.md) diff --git a/docs-es/v2.0/troubleshooting-es.md b/docs-es/v2.0/troubleshooting-es.md index 29dc1f17..d645dcae 100644 --- a/docs-es/v2.0/troubleshooting-es.md +++ b/docs-es/v2.0/troubleshooting-es.md @@ -8,7 +8,7 @@ Use esta tabla para ir al primer check rápidamente. | Síntoma | Primer check | Ubicación en WebUI | Siguiente sección | |---|---|---|---| -| No se puede acceder a la WebUI | `systemctl status lqosd` | N/A (UI no disponible) | No hay WebUI en x.x.x.x:9123 | +| No se puede acceder a la WebUI | `systemctl status lqosd caddy` | N/A (UI no disponible) | No hay WebUI en x.x.x.x:9123 o en la URL HTTPS | | Hay tráfico pero no hace shaping | verificar `to_internet` / `to_network` y servicios | WebUI Dashboard | LibreQoS está en ejecución, pero no hace shaping | | Scheduler no saludable | revisar logs de `lqosd` y `lqos_scheduler` | WebUI -> Scheduler Status | El estado del scheduler en WebUI aparece no saludable | | Vistas de topología/flujo vacías | confirmar tráfico reciente y estado de `lqosd` | WebUI -> Flow Globe / Tree / ASN Analysis | Flow Globe / Tree Overview / ASN Analysis aparecen en blanco | @@ -62,16 +62,23 @@ sudo systemctl restart lqosd 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. +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` si SSL está deshabilitado; la WebUI debería redirigirlo automáticamente al flujo de primer inicio. -### No hay WebUI en x.x.x.x:9123 +### No hay WebUI en x.x.x.x:9123 o en la URL HTTPS -La WebUI depende de `lqosd`. En builds actuales, la mayoría de fallas de acceso WebUI se explican por `lqosd` no saludable. +La WebUI depende de `lqosd`. Si HTTPS opcional con Caddy está habilitado, `caddy` también debe estar saludable. ```bash -sudo systemctl status lqosd +sudo systemctl status lqosd caddy ``` +Luego: + +- Si SSL está deshabilitado, pruebe `http://tu-ip-de-gestión:9123/` +- Si SSL está habilitado con hostname, pruebe `https://tu-hostname/` +- Si SSL está habilitado sin hostname, pruebe `https://tu-ip-de-gestión/` +- Si el navegador advierte en modo de certificado local, confíe en `/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt` en la estación de trabajo del operador + Luego siga el flujo completo en **El servicio lqosd no se ejecuta o falla al iniciar**. ### LibreQoS está en ejecución, pero no hace shaping diff --git a/docs/v2.0/api.md b/docs/v2.0/api.md index 56a58e46..e1ee773f 100644 --- a/docs/v2.0/api.md +++ b/docs/v2.0/api.md @@ -13,7 +13,8 @@ This is separate from base shaping limits: Use Swagger on your node as the complete reference and test surface for your installed build: -- `http://:9122/api-docs` +- Direct local API port: `http://:9122/api-docs` +- If optional HTTPS with Caddy is enabled for the WebUI: `https:///api/v1/api-docs` Use this page as a capability map. Use Swagger for full endpoint inventory, request/response schemas, and live testing. @@ -142,3 +143,5 @@ If integrations are enabled, integration refresh cycles may overwrite direct fil - Do not expose the API directly to the public Internet. - If remote access is needed, use a reverse proxy with TLS and authentication. - Restrict inbound access with firewall allowlists. + +If you want the WebUI and API docs on one HTTPS origin, see [Optional HTTPS With Caddy](https-caddy.md). diff --git a/docs/v2.0/components.md b/docs/v2.0/components.md index bc4bdf7b..90f3cb85 100644 --- a/docs/v2.0/components.md +++ b/docs/v2.0/components.md @@ -10,7 +10,8 @@ flowchart LR B --> E[Queue/shaping plan refresh] E --> F[lqosd] F --> G[XDP/TC shaping runtime] - F --> H[WebUI :9123] + F --> H[WebUI service :9123] + J[Optional Caddy HTTPS :443] --> H B --> I[Scheduler Status / Urgent Issues] F --> I ``` @@ -19,7 +20,8 @@ flowchart LR - Manages actual XDP code. - Coded in Rust. -- Runs the GUI available at http://a.b.c.d:9123 +- Runs the GUI available at `http://a.b.c.d:9123` by default. +- If optional HTTPS is enabled, Caddy serves `https://hostname/` or `https://management-ip/` and proxies the WebUI to `127.0.0.1:9123`. - Hosts WebUI pages such as: - Site Map - Flow Globe diff --git a/docs/v2.0/configuration.md b/docs/v2.0/configuration.md index 55d42d5b..d2edb142 100644 --- a/docs/v2.0/configuration.md +++ b/docs/v2.0/configuration.md @@ -18,6 +18,8 @@ After installing the package: 4. Open `Complete Setup` 5. Choose how LibreQoS will receive subscriber and topology data +If you want operators to use HTTPS, open `Configuration -> SSL Setup` after sign-in, or enable the same option during first-run setup. See [Optional HTTPS With Caddy](https-caddy.md). + For most operators, `Complete Setup` is where the important early decision happens: - Built-in integration for UISP, Splynx, VISP, Netzur, Powercode, Sonar, or WispGate - Custom importer if your own process writes `network.json` and `ShapedDevices.csv` @@ -27,13 +29,14 @@ If Scheduler Status still says `Setup Required`, LibreQoS is not ready to shape ## Configuration Via Web Interface -Most day-to-day LibreQoS configuration happens in WebUI (`http://your_shaper_ip:9123/config_general.html`). +Most day-to-day LibreQoS configuration happens in WebUI. By default that is `http://your_shaper_ip:9123/config_general.html`. If you enable optional HTTPS with Caddy, operators use `https://your-hostname/` or `https://your-management-ip/` instead. Current builds use a consistent configuration layout across the General, RTT, Queues, TreeGuard, Network Mode, Integration Defaults, Network Layout, Insight, provider integration, IP Ranges, Flow Tracking, and Shaped Devices pages. Integration Defaults also includes the shared Ethernet port headroom policy used by integrations that can detect negotiated subscriber-facing port speeds. ### Where In WebUI - General settings: `Configuration -> General` +- HTTPS setup: `Configuration -> SSL Setup` - Integration settings: `Configuration -> Integrations` - Network layout editor: `Configuration -> Network Layout` - Shaped devices editor: `Configuration -> Shaped Devices` @@ -101,6 +104,7 @@ For direct file editing (`/etc/lqos.conf`, `network.json`, `ShapedDevices.csv`), ## Related Pages - [Quickstart](quickstart.md) +- [Optional HTTPS With Caddy](https-caddy.md) - [Operating Modes and Source of Truth](operating-modes.md) - [CRM/NMS Integrations](integrations.md) - [LibreQoS WebUI (Node Manager)](node-manager-ui.md) diff --git a/docs/v2.0/git-install.md b/docs/v2.0/git-install.md index 4c4ad0ba..39155cd8 100644 --- a/docs/v2.0/git-install.md +++ b/docs/v2.0/git-install.md @@ -94,3 +94,5 @@ sudo systemctl start lqosd lqos_scheduler ``` You can now point a web browser at `http://a.b.c.d:9123` (replace `a.b.c.d` with the management IP address of your shaping server) and enjoy a real-time view of your network. + +If you want operators to use HTTPS instead, sign in and open `Configuration -> SSL Setup`. See [Optional HTTPS With Caddy](https-caddy.md). diff --git a/docs/v2.0/https-caddy.md b/docs/v2.0/https-caddy.md new file mode 100644 index 00000000..401c3950 --- /dev/null +++ b/docs/v2.0/https-caddy.md @@ -0,0 +1,51 @@ +# Optional HTTPS With Caddy + +LibreQoS can run the WebUI and API docs behind Caddy so operators can use HTTPS. This is optional. LibreQoS still works without it. + +## When To Use It + +Use `Configuration -> SSL Setup` when you want operators to open LibreQoS with HTTPS instead of plain HTTP on port `9123`. + +You can also enable the same option during the first-run setup flow. + +## Two Certificate Modes + +LibreQoS supports two simple choices: + +- Enter an external hostname such as `libreqos.example.com`: Caddy requests a public certificate from Let's Encrypt. Browsers should trust it automatically once DNS and inbound access are correct. +- Leave the hostname blank: Caddy secures LibreQoS by management IP address with Caddy's local certificate authority. Traffic is still encrypted, but operator computers must trust Caddy's local root certificate before browser warnings stop. + +## What Changes After You Enable It + +- Operators stop using `http://your_shaper_ip:9123` and instead use `https://your-hostname/` or `https://your-management-ip/`. +- LibreQoS moves the WebUI listener to `127.0.0.1:9123`. +- Caddy proxies the WebUI and API docs over HTTPS. +- Swagger moves to `/api/v1/api-docs` on the same HTTPS origin as the WebUI. + +## If You Use The Local Certificate Mode + +When you leave the hostname blank, the Caddy local root certificate is stored on the LibreQoS host at: + +```text +/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt +``` + +Trust that certificate on each operator workstation that will open the HTTPS WebUI. + +## Disable HTTPS + +If you want to back out, open `Configuration -> SSL Setup` and choose `Disable SSL`. + +LibreQoS then: + +- removes the managed Caddy configuration +- restores the previous direct WebUI listener, or the normal default `:::9123` if no custom listener was set first +- returns operators to direct HTTP access on the management IP and port `9123` + +## Related Pages + +- [Quickstart](quickstart.md) +- [Configure LibreQoS](configuration.md) +- [LibreQoS WebUI (Node Manager)](node-manager-ui.md) +- [LibreQoS Node API](api.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index b59bbec8..bc0065ea 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -1,6 +1,6 @@ # LibreQoS WebUI (Node Manager) -This page documents key WebUI (Node Manager) views and operational behavior in the local WebUI (`http://your_shaper_ip:9123`). +This page documents key WebUI (Node Manager) views and operational behavior in the local WebUI. By default operators use `http://your_shaper_ip:9123`. If optional HTTPS with Caddy is enabled, operators use `https://your-hostname/` or `https://your-management-ip/` instead. For the full logical-topology versus queue-topology file flow, see [Topology Data Flow](topology-data-flow.md). @@ -173,6 +173,7 @@ Practical meaning: - Saved passwords, API tokens, and keys stay hidden when you open an integration page. - If a credential is already configured, the field stays blank until you type a replacement. Use `Clear` only when you want to remove the saved value. - Leave the field blank to keep the current value. Enter a new value to replace it. +- `Configuration -> SSL Setup` can install Caddy, switch the WebUI behind HTTPS, and later disable that HTTPS setup if you want to return to direct HTTP on port `9123`. ### Urgent Issues - WebUI can display urgent operational issues reported by LibreQoS services. @@ -221,4 +222,5 @@ If only Site Map is blank or slow while other pages are healthy, also check mana - [Components](components.md) - [Configuration](configuration.md) +- [Optional HTTPS With Caddy](https-caddy.md) - [Troubleshooting](troubleshooting.md) diff --git a/docs/v2.0/quickstart.md b/docs/v2.0/quickstart.md index 344a46d6..81fa4a3e 100644 --- a/docs/v2.0/quickstart.md +++ b/docs/v2.0/quickstart.md @@ -61,6 +61,8 @@ sudo apt install ./{deb_url_v1_5} 3. Create the initial WebUI admin user if prompted. 4. Sign in and confirm the Dashboard opens. +Optional: if operators need HTTPS, enable `Configuration -> SSL Setup` after sign-in, or enable the same option during first-run setup. See [Optional HTTPS With Caddy](https-caddy.md). + At this point, the WebUI being available does not yet prove that LibreQoS is ready to shape subscribers. If Scheduler Status says `Setup Required`, that is expected until you choose a topology source and publish usable shaping data. @@ -172,6 +174,7 @@ Expand only after you have one known-good baseline. ## 8) Related Pages - [Configure LibreQoS](configuration.md) +- [Optional HTTPS With Caddy](https-caddy.md) - [Operating Modes and Source of Truth](operating-modes.md) - [CRM/NMS Integrations](integrations.md) - [Advanced Configuration Reference](configuration-advanced.md) diff --git a/docs/v2.0/troubleshooting.md b/docs/v2.0/troubleshooting.md index c4c5ee96..16d85100 100644 --- a/docs/v2.0/troubleshooting.md +++ b/docs/v2.0/troubleshooting.md @@ -8,7 +8,7 @@ Need definitions for licensing/scheduler terms? See the [Glossary](glossary.md). | Symptom | First check | WebUI location | Next section | |---|---|---|---| -| Cannot access WebUI | `systemctl status lqosd` | N/A (UI unavailable) | No WebUI at x.x.x.x:9123 | +| Cannot access WebUI | `systemctl status lqosd caddy` | N/A (UI unavailable) | No WebUI at x.x.x.x:9123 or HTTPS URL | | Traffic is not shaping | verify `to_internet` / `to_network`, service state | WebUI Dashboard | LibreQoS Is Running, But Traffic Not Shaping | | Scheduler appears unhealthy | check `lqosd` and `lqos_scheduler` logs | WebUI -> Scheduler Status | Scheduler status in WebUI looks unhealthy | | Topology/flow views blank | confirm recent traffic and `lqosd` health | WebUI -> Flow Globe / Tree / ASN Analysis | Flow Globe / Tree Overview / ASN Analysis appears blank | @@ -63,17 +63,24 @@ If the correct username/password still fails, first restart `lqosd` and try agai sudo systemctl restart lqosd ``` -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. +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` if SSL is disabled; the WebUI should redirect you to first-run setup automatically. -### No WebUI at x.x.x.x:9123 +### No WebUI at x.x.x.x:9123 or HTTPS URL -The WebUI is controlled by the `lqosd` service. In current builds, most WebUI access failures are caused by `lqosd` not being healthy. +The WebUI is controlled by `lqosd`. If optional HTTPS with Caddy is enabled, `caddy` also has to be healthy. Start by checking: ``` -sudo systemctl status lqosd +sudo systemctl status lqosd caddy ``` +Then: + +- If SSL is disabled, test `http://your-management-ip:9123/` +- If SSL is enabled with a hostname, test `https://your-hostname/` +- If SSL is enabled without a hostname, test `https://your-management-ip/` +- If browsers warn in local-certificate mode, trust `/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt` on the operator workstation + Then follow the full workflow in **Service lqosd is not running or failed to start** below. ### LibreQoS Is Running, But Traffic Not Shaping diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index 66388a1d..d24f86ec 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -42,6 +42,8 @@ LQOS_FILES=( ShapedDevices.example.csv shaping_skip_report.py systemd_hotfix.sh + install_caddy.sh + disable_caddy.sh virtual_tree_nodes.py manualNetwork.template.csv ../requirements.txt @@ -226,9 +228,11 @@ fi cp mikrotik_ipv6.example.toml "$ETC_LIBREQOS_DIR"/mikrotik_ipv6.example.toml # 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 -fi +for helper_script in update_api.sh install_caddy.sh disable_caddy.sh; do + if [ -f "$LQOS_DIR/$helper_script" ]; then + chmod a+x "$LQOS_DIR/$helper_script" || true + fi +done # Copy files into the LibreQoS/bin directory for file in "${LQOS_BIN_FILES[@]}"; do diff --git a/src/disable_caddy.sh b/src/disable_caddy.sh new file mode 100755 index 00000000..dff8c5fb --- /dev/null +++ b/src/disable_caddy.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "disable_caddy.sh must be run as root." >&2 + exit 1 +fi + +rm -f /etc/caddy/Caddyfile + +if systemctl list-unit-files caddy.service >/dev/null 2>&1; then + systemctl stop caddy.service || true + systemctl disable caddy.service || true +fi + +echo "Removed the LibreQoS-managed Caddyfile and disabled Caddy." diff --git a/src/install_caddy.sh b/src/install_caddy.sh new file mode 100755 index 00000000..86197dd2 --- /dev/null +++ b/src/install_caddy.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "install_caddy.sh must be run as root." >&2 + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive + +if command -v caddy >/dev/null 2>&1; then + echo "Caddy is already installed." + exit 0 +fi + +apt-get update +apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg + +install -d -m 0755 /usr/share/keyrings +curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \ + -o /etc/apt/sources.list.d/caddy-stable.list + +apt-get update +apt-get install -y caddy + +echo "Caddy installation complete." diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 2d7f16bc..49ff6e4f 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2416,6 +2416,7 @@ dependencies = [ "axum", "clap", "cursive", + "default-net", "ip_network", "libc", "lqos_config", @@ -2566,6 +2567,7 @@ dependencies = [ "lqos_overrides", "lqos_probe", "lqos_queue_tracker", + "lqos_setup", "lqos_stormguard", "lqos_sys", "lqos_topology", diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index a796e2c8..32621138 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -20,9 +20,10 @@ pub mod test_data; mod v15; pub use v15::{ BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, - MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, - StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, + MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, SslConfig, + StormguardConfig, StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, + normalize_external_hostname, }; static CONFIG: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None)); diff --git a/src/rust/lqos_config/src/etc/v15/example.toml b/src/rust/lqos_config/src/etc/v15/example.toml index 730f272d..8a6c5b52 100644 --- a/src/rust/lqos_config/src/etc/v15/example.toml +++ b/src/rust/lqos_config/src/etc/v15/example.toml @@ -33,6 +33,11 @@ to_network = "eth1" #internet_vlan = 2 #network_vlan = 3 +#[ssl] +#enabled = false +#external_hostname = "libreqos.example.com" +#managed_by_libreqos = false + [queues] default_sqm = "cake diffserv4" queue_mode = "shape" diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs index c2db9307..2dc4d6e6 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -3,6 +3,7 @@ mod top_config; pub use top_config::Config; pub use top_config::RttThresholds; +pub use top_config::{SslConfig, normalize_external_hostname}; mod bridge; mod dynamic_circuits; mod flows; diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index a5a0cfcf..90c13b47 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -27,6 +27,10 @@ fn default_rtt_red_ms() -> u32 { 200 } +fn default_false() -> bool { + false +} + /// RTT color scale thresholds (milliseconds) used by the web UI. /// /// These represent the color "anchor points" for RTT: @@ -56,6 +60,48 @@ impl Default for RttThresholds { } } +/// Optional HTTPS/Caddy settings for the LibreQoS operator UI. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)] +pub struct SslConfig { + /// Whether LibreQoS should run the WebUI behind a local Caddy proxy. + #[serde(default = "default_false")] + pub enabled: bool, + /// Optional public hostname used for trusted public certificates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_hostname: Option, + /// Whether LibreQoS owns and manages the generated Caddy configuration. + #[serde(default = "default_false")] + pub managed_by_libreqos: bool, + /// Previous direct WebUI listen address restored when SSL is disabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_webserver_listen: Option, +} + +/// Normalizes and validates an optional external hostname for HTTPS/Caddy use. +pub fn normalize_external_hostname(hostname: &str) -> Result, String> { + let normalized = hostname.trim().trim_end_matches('.').to_string(); + if normalized.is_empty() { + return Ok(None); + } + if normalized.contains("://") + || normalized.contains('/') + || normalized.contains(':') + || normalized.chars().any(char::is_whitespace) + { + return Err( + "ssl.external_hostname must be a hostname only, without a scheme, path, or port." + .to_string(), + ); + } + if normalized.parse::().is_ok() { + return Err( + "ssl.external_hostname must be a DNS hostname. Leave it blank to use the management IP with a local certificate." + .to_string(), + ); + } + Ok(Some(normalized)) +} + /// Top-level configuration file for LibreQoS. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] pub struct Config { @@ -169,6 +215,10 @@ pub struct Config { /// Listen options for the webserver pub webserver_listen: Option, + /// Optional HTTPS/Caddy configuration for the WebUI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl: Option, + /// Controls whether an operator-provided cobrand image should be shown in the WebUI. #[serde(default)] pub display_cobrand: bool, @@ -254,6 +304,12 @@ impl Config { ); } } + if let Some(ssl) = &self.ssl + && let Some(hostname) = ssl.external_hostname.as_deref() + && normalize_external_hostname(hostname)?.is_none() + { + return Err("ssl.external_hostname must not be empty when configured".to_string()); + } if let Some(multiplier) = self.integration_common.ethernet_port_limit_multiplier && (!multiplier.is_finite() || multiplier <= 0.0 || multiplier > 1.0) { @@ -382,6 +438,7 @@ impl Default for Config { flows: None, disable_webserver: None, webserver_listen: None, + ssl: None, display_cobrand: false, stormguard: None, treeguard: treeguard::TreeguardConfig::default(), @@ -569,7 +626,7 @@ impl Config { #[cfg(test)] mod test { - use super::{Config, RttThresholds}; + use super::{Config, RttThresholds, SslConfig, normalize_external_hostname}; fn remove_sections(raw: &str, sections: &[&str]) -> String { let mut output = Vec::new(); @@ -597,7 +654,9 @@ mod test { raw.lines() .filter(|line| { let trimmed = line.trim_start(); - !keys.iter().any(|key| trimmed.starts_with(&format!("{key} ="))) + !keys + .iter() + .any(|key| trimmed.starts_with(&format!("{key} ="))) }) .collect::>() .join("\n") @@ -815,6 +874,54 @@ mod test { assert!(!config.display_cobrand); } + #[test] + fn ssl_hostname_validation_rejects_scheme_path_whitespace_ip_and_port() { + for invalid in [ + "https://libreqos.example.com", + "libreqos.example.com/path", + "libreqos example.com", + "192.0.2.1", + "libreqos.example.com:8443", + ] { + let error = + normalize_external_hostname(invalid).expect_err("invalid hostname should fail"); + assert!(error.contains("hostname only") || error.contains("DNS hostname")); + } + } + + #[test] + fn ssl_hostname_round_trips_in_config() { + let mut config = Config::default(); + config.ssl = Some(SslConfig { + enabled: true, + external_hostname: Some("libreqos.example.com".to_string()), + managed_by_libreqos: true, + previous_webserver_listen: Some("192.0.2.10:9123".to_string()), + }); + config.validate().expect("ssl config should validate"); + + let serialized = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(serialized.contains("[ssl]")); + assert!(serialized.contains("external_hostname = \"libreqos.example.com\"")); + assert!(serialized.contains("previous_webserver_listen = \"192.0.2.10:9123\"")); + + let round_trip = Config::load_from_string(&serialized).expect("config should deserialize"); + assert_eq!( + round_trip + .ssl + .as_ref() + .and_then(|ssl| ssl.external_hostname.as_deref()), + Some("libreqos.example.com") + ); + assert_eq!( + round_trip + .ssl + .as_ref() + .and_then(|ssl| ssl.previous_webserver_listen.as_deref()), + Some("192.0.2.10:9123") + ); + } + #[test] fn treeguard_validation_rejects_invalid_thresholds() { let mut cfg = Config::default(); diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 2e54fd6b..145cbb0c 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -42,11 +42,11 @@ pub use cpu_topology::{ }; pub use etc::{ BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, - MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, - StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, + MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, SslConfig, + StormguardConfig, StormguardStrategy, TopologyConfig, 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, + normalize_external_hostname, treeguard_cpu_mode_migration_notice, update_config, }; pub use ethernet_port_limits::{ DEFAULT_ETHERNET_PORT_LIMIT_MULTIPLIER, EthernetPortLimitPolicy, EthernetPortObservation, diff --git a/src/rust/lqos_setup/Cargo.toml b/src/rust/lqos_setup/Cargo.toml index eccd9c2a..4e422246 100644 --- a/src/rust/lqos_setup/Cargo.toml +++ b/src/rust/lqos_setup/Cargo.toml @@ -21,3 +21,4 @@ uuid = { workspace = true } tokio = { workspace = true } axum = { version = "0.7.7" } libc = "0.2" +default-net = { workspace = true } diff --git a/src/rust/lqos_setup/src/lib.rs b/src/rust/lqos_setup/src/lib.rs index 1aa39990..e87f6110 100644 --- a/src/rust/lqos_setup/src/lib.rs +++ b/src/rust/lqos_setup/src/lib.rs @@ -2,5 +2,6 @@ pub mod bootstrap; pub mod hotfix; +pub mod ssl; pub use bootstrap::PostinstAction; diff --git a/src/rust/lqos_setup/src/ssl.rs b/src/rust/lqos_setup/src/ssl.rs new file mode 100644 index 00000000..c8626aa5 --- /dev/null +++ b/src/rust/lqos_setup/src/ssl.rs @@ -0,0 +1,718 @@ +//! Optional Caddy-based HTTPS management shared by `lqosd` and `lqos_setup`. + +use anyhow::{Context, Result, bail}; +use default_net::get_default_interface; +use lqos_config::{Config, SslConfig, normalize_external_hostname}; +use nix::{ + ifaddrs::getifaddrs, + sys::socket::{AddressFamily, SockaddrLike}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + net::IpAddr, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +const CADDYFILE_PATH: &str = "/etc/caddy/Caddyfile"; +const INTERNAL_CA_CERT_PATH: &str = + "/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt"; +const INSTALL_CADDY_SCRIPT: &str = "install_caddy.sh"; +const DISABLE_CADDY_SCRIPT: &str = "disable_caddy.sh"; +const RUNTIME_DIRECT_LISTEN: &str = ":::9123"; +const RUNTIME_SECURE_LISTEN: &str = "127.0.0.1:9123"; +const API_UPSTREAM: &str = "127.0.0.1:9122"; +const WEB_UPSTREAM: &str = "127.0.0.1:9123"; +const DELAYED_SWITCH_SHELL: &str = "/bin/sh"; + +/// The effective HTTPS mode for the current or requested config. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SslStatus { + /// Whether SSL mode is enabled in the LibreQoS config. + pub enabled: bool, + /// Whether LibreQoS owns the generated Caddy configuration. + pub managed_by_libreqos: bool, + /// Optional public hostname used for trusted public certificates. + pub external_hostname: Option, + /// Whether this config uses Caddy's internal certificate authority. + pub using_internal_ca: bool, + /// The operator-facing HTTPS URL for the current or requested state. + pub target_url: String, + /// The current WebUI listen address stored in the LibreQoS config. + pub webserver_listen: Option, + /// Whether the Caddy binary is currently available on the host. + pub caddy_installed: bool, + /// Whether the managed Caddyfile currently exists on disk. + pub caddyfile_present: bool, + /// The local root certificate path used by internal-CA mode. + pub internal_ca_root_certificate: Option, +} + +/// Outcome returned to operator-facing callers after an SSL action is queued or applied. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SslActionOutcome { + /// Human-readable status message. + pub message: String, + /// The operator-facing URL to open after the action completes. + pub target_url: String, + /// Whether this action uses Caddy's internal certificate authority. + pub using_internal_ca: bool, + /// The local root certificate path used by internal-CA mode. + pub internal_ca_root_certificate: Option, +} + +#[derive(Clone, Debug)] +struct SslRuntimePlan { + external_hostname: Option, + target_url: String, + using_internal_ca: bool, + caddy_site_address: String, +} + +/// Builds an operator-facing SSL status view from the current config and host state. +pub fn ssl_status(config: &Config, preferred_host: Option<&str>) -> SslStatus { + let ssl = config.ssl.clone().unwrap_or_default(); + let normalized_hostname = normalized_config_hostname(ssl.external_hostname.as_deref()); + let plan = build_runtime_plan(normalized_hostname.clone(), preferred_host); + SslStatus { + enabled: ssl.enabled, + managed_by_libreqos: ssl.managed_by_libreqos, + external_hostname: normalized_hostname, + using_internal_ca: plan.using_internal_ca, + target_url: if ssl.enabled { + plan.target_url + } else { + direct_runtime_url(config, preferred_host) + }, + webserver_listen: config.webserver_listen.clone(), + caddy_installed: caddy_binary_available(), + caddyfile_present: Path::new(CADDYFILE_PATH).exists(), + internal_ca_root_certificate: plan + .using_internal_ca + .then(|| INTERNAL_CA_CERT_PATH.to_string()), + } +} + +/// Enables HTTPS for the existing runtime install and schedules the live WebUI handoff. +/// +/// Side effects: +/// - updates `/etc/lqos.conf` +/// - runs the Caddy installer helper if needed +/// - writes `/etc/caddy/Caddyfile` +/// - spawns a delayed background shell that restarts `caddy.service` and `lqosd.service` +pub fn enable_runtime_ssl( + external_hostname: Option, + preferred_host: Option<&str>, +) -> Result { + let existing = (*lqos_config::load_config()?).clone(); + let mut updated = existing.clone(); + let plan = apply_ssl_to_config(&mut updated, external_hostname, preferred_host)?; + let caddyfile_backup = read_existing_caddyfile()?; + let caddy_service_state = capture_service_state("caddy.service"); + persist_with_rollback(&updated)?; + if let Err(err) = install_caddy(&updated) + .and_then(|_| write_managed_caddyfile(&updated, preferred_host)) + .and_then(|_| schedule_delayed_runtime_switch(enable_runtime_command())) + { + let _ = lqos_config::update_config(&existing); + let _ = restore_caddyfile(&caddyfile_backup); + let _ = restore_service_state("caddy.service", caddy_service_state); + bail!("{err:#}"); + } + + Ok(SslActionOutcome { + message: if plan.using_internal_ca { + format!( + "Queued HTTPS setup with Caddy. LibreQoS will switch to {} and use Caddy's local certificate authority.", + plan.target_url + ) + } else { + format!( + "Queued HTTPS setup with Caddy. LibreQoS will switch to {} and request a trusted public certificate.", + plan.target_url + ) + }, + target_url: plan.target_url, + using_internal_ca: plan.using_internal_ca, + internal_ca_root_certificate: plan + .using_internal_ca + .then(|| INTERNAL_CA_CERT_PATH.to_string()), + }) +} + +/// Disables HTTPS for the existing runtime install and schedules the direct WebUI handoff. +/// +/// Side effects: +/// - updates `/etc/lqos.conf` +/// - removes the managed `/etc/caddy/Caddyfile` +/// - runs the Caddy disable helper when LibreQoS owns that setup +/// - spawns a delayed background shell that restarts `lqosd.service` +pub fn disable_runtime_ssl(preferred_host: Option<&str>) -> Result { + let existing = (*lqos_config::load_config()?).clone(); + let ssl = existing + .ssl + .as_ref() + .ok_or_else(|| anyhow::anyhow!("LibreQoS is not managing HTTPS on this node."))?; + if !ssl.managed_by_libreqos { + bail!("LibreQoS is not managing HTTPS on this node."); + } + let mut updated = existing.clone(); + let caddyfile_backup = read_existing_caddyfile()?; + let caddy_service_state = capture_service_state("caddy.service"); + disable_ssl_in_config(&mut updated); + persist_with_rollback(&updated)?; + if let Err(err) = run_disable_caddy_script(&updated) + .and_then(|_| schedule_delayed_runtime_switch(disable_runtime_command())) + { + let _ = lqos_config::update_config(&existing); + let _ = restore_caddyfile(&caddyfile_backup); + let _ = restore_service_state("caddy.service", caddy_service_state); + bail!("{err:#}"); + } + + Ok(SslActionOutcome { + message: format!( + "Queued HTTPS shutdown. LibreQoS will return to direct access at {}.", + direct_runtime_url(&updated, preferred_host) + ), + target_url: direct_runtime_url(&updated, preferred_host), + using_internal_ca: false, + internal_ca_root_certificate: None, + }) +} + +/// Enables HTTPS in a setup flow before runtime services take over. +/// +/// Side effects: +/// - updates `/etc/lqos.conf` +/// - runs the Caddy installer helper if needed +/// - writes `/etc/caddy/Caddyfile` +/// - enables and restarts `caddy.service` immediately +pub fn enable_setup_ssl( + config: &Config, + external_hostname: Option, + preferred_host: Option<&str>, +) -> Result { + let existing = config.clone(); + let mut updated = config.clone(); + let plan = apply_ssl_to_config(&mut updated, external_hostname, preferred_host)?; + let caddyfile_backup = read_existing_caddyfile()?; + let caddy_service_state = capture_service_state("caddy.service"); + lqos_config::update_config(&updated)?; + if let Err(err) = install_caddy(&updated) + .and_then(|_| write_managed_caddyfile(&updated, preferred_host)) + .and_then(|_| { + run_shell_command_now(enable_setup_command()) + .context("Unable to activate Caddy for setup") + }) + { + let _ = lqos_config::update_config(&existing); + let _ = restore_caddyfile(&caddyfile_backup); + let _ = restore_service_state("caddy.service", caddy_service_state); + bail!("{err:#}"); + } + + Ok(SslActionOutcome { + message: if plan.using_internal_ca { + format!( + "Configured optional HTTPS via Caddy. After runtime services start, open {} and trust the local root certificate on operator workstations.", + plan.target_url + ) + } else { + format!( + "Configured optional HTTPS via Caddy. After runtime services start, open {}.", + plan.target_url + ) + }, + target_url: plan.target_url, + using_internal_ca: plan.using_internal_ca, + internal_ca_root_certificate: plan + .using_internal_ca + .then(|| INTERNAL_CA_CERT_PATH.to_string()), + }) +} + +fn persist_with_rollback(updated: &Config) -> Result<()> { + updated.validate().map_err(anyhow::Error::msg)?; + lqos_config::update_config(updated).with_context(|| "Unable to persist LibreQoS config")?; + Ok(()) +} + +fn apply_ssl_to_config( + config: &mut Config, + external_hostname: Option, + preferred_host: Option<&str>, +) -> Result { + if config.disable_webserver.unwrap_or(false) { + bail!("Disable Web Server is enabled. Re-enable the WebUI before setting up SSL."); + } + + let plan = build_runtime_plan( + normalize_external_hostname(external_hostname.as_deref().unwrap_or("")) + .map_err(anyhow::Error::msg)?, + preferred_host, + ); + let previous_webserver_listen = config + .webserver_listen + .as_ref() + .filter(|listen| listen.as_str() != RUNTIME_SECURE_LISTEN) + .cloned() + .or_else(|| { + config + .ssl + .as_ref() + .and_then(|ssl| ssl.previous_webserver_listen.clone()) + }) + .or_else(|| Some(RUNTIME_DIRECT_LISTEN.to_string())); + config.webserver_listen = Some(RUNTIME_SECURE_LISTEN.to_string()); + config.ssl = Some(SslConfig { + enabled: true, + external_hostname: plan.external_hostname.clone(), + managed_by_libreqos: true, + previous_webserver_listen, + }); + config.validate().map_err(anyhow::Error::msg)?; + Ok(plan) +} + +fn disable_ssl_in_config(config: &mut Config) { + let mut restore_listen = Some(RUNTIME_DIRECT_LISTEN.to_string()); + if let Some(ssl) = config.ssl.as_mut() { + ssl.enabled = false; + restore_listen = ssl + .previous_webserver_listen + .clone() + .filter(|listen| !listen.trim().is_empty()) + .or(restore_listen); + } + config.webserver_listen = restore_listen; +} + +fn build_runtime_plan( + external_hostname: Option, + preferred_host: Option<&str>, +) -> SslRuntimePlan { + if let Some(hostname) = external_hostname { + let target_url = format!("https://{hostname}/"); + return SslRuntimePlan { + external_hostname: Some(hostname.clone()), + target_url, + using_internal_ca: false, + caddy_site_address: hostname, + }; + } + + let access_host = select_internal_access_host(preferred_host); + SslRuntimePlan { + external_hostname: None, + target_url: format!("https://{}/", url_host(&access_host)), + using_internal_ca: true, + caddy_site_address: format!("https://{}", url_host(&access_host)), + } +} + +fn direct_runtime_url(config: &Config, preferred_host: Option<&str>) -> String { + let access_host = normalize_preferred_host(preferred_host) + .unwrap_or_else(|| detect_management_ip().to_string()); + let port = config + .webserver_listen + .as_deref() + .and_then(listen_port) + .unwrap_or(9123); + format!("http://{}:{port}/", url_host(&access_host)) +} + +fn detect_management_ip() -> IpAddr { + if let Ok(interface) = get_default_interface() { + if let Some(ip) = interface + .ipv4 + .into_iter() + .map(|network| network.addr) + .find(|ip| !ip.is_loopback()) + { + return IpAddr::V4(ip); + } + if let Some(ip) = interface + .ipv6 + .into_iter() + .map(|network| network.addr) + .find(|ip| !ip.is_loopback() && !ip.is_unspecified()) + { + return IpAddr::V6(ip); + } + } + + if let Ok(ifaddrs) = getifaddrs() { + for iface in ifaddrs { + if iface.interface_name == "lo" { + continue; + } + let Some(address) = iface.address else { + continue; + }; + match address.family() { + Some(AddressFamily::Inet) => { + let Some(inet) = address.as_sockaddr_in() else { + continue; + }; + let ip = inet.ip(); + if !ip.is_loopback() { + return IpAddr::V4(ip); + } + } + Some(AddressFamily::Inet6) => { + let Some(inet) = address.as_sockaddr_in6() else { + continue; + }; + let ip = inet.ip(); + if !ip.is_loopback() && !ip.is_unspecified() { + return IpAddr::V6(ip); + } + } + _ => continue, + } + } + } + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) +} + +fn select_internal_access_host(preferred_host: Option<&str>) -> String { + normalize_preferred_host(preferred_host).unwrap_or_else(|| detect_management_ip().to_string()) +} + +fn normalized_config_hostname(hostname: Option<&str>) -> Option { + hostname.and_then(|hostname| normalize_external_hostname(hostname).ok().flatten()) +} + +fn normalize_preferred_host(preferred_host: Option<&str>) -> Option { + let host = preferred_host?.trim(); + if host.is_empty() { + return None; + } + if let Some(stripped) = host.strip_prefix('[') { + if let Some((ipv6, _port)) = stripped.split_once("]:") { + return sanitize_candidate_host(ipv6); + } + if let Some(ipv6) = stripped.strip_suffix(']') { + return sanitize_candidate_host(ipv6); + } + } + if let Some((name, port)) = host.rsplit_once(':') + && port.chars().all(|c| c.is_ascii_digit()) + && !name.contains(':') + { + return sanitize_candidate_host(name); + } + sanitize_candidate_host(host) +} + +fn sanitize_candidate_host(host: &str) -> Option { + if let Ok(ip) = host.parse::() { + return Some(ip.to_string()); + } + normalize_external_hostname(host).ok().flatten() +} + +fn url_host(host: &str) -> String { + host.parse::() + .ok() + .and_then(|ip| match ip { + IpAddr::V6(_) => Some(format!("[{host}]")), + IpAddr::V4(_) => None, + }) + .unwrap_or_else(|| host.to_string()) +} + +fn listen_port(listen: &str) -> Option { + let port = listen.rsplit_once(':')?.1; + port.parse::().ok() +} + +#[derive(Clone, Copy)] +struct ServiceState { + enabled: bool, + active: bool, +} + +fn capture_service_state(service_name: &str) -> ServiceState { + ServiceState { + enabled: command_succeeds("systemctl", &["is-enabled", "--quiet", service_name]), + active: command_succeeds("systemctl", &["is-active", "--quiet", service_name]), + } +} + +fn restore_service_state(service_name: &str, state: ServiceState) -> Result<()> { + if state.active { + run_shell_command_now(&format!("systemctl restart {service_name}"))?; + } else { + run_shell_command_now(&format!("systemctl stop {service_name}"))?; + } + if state.enabled { + run_shell_command_now(&format!("systemctl enable {service_name}"))?; + } else { + run_shell_command_now(&format!("systemctl disable {service_name}"))?; + } + Ok(()) +} + +fn command_succeeds(command: &str, args: &[&str]) -> bool { + Command::new(command) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn install_caddy(config: &Config) -> Result<()> { + let script = runtime_script_path(config, INSTALL_CADDY_SCRIPT)?; + let status = Command::new("/bin/bash") + .arg(&script) + .status() + .with_context(|| format!("Unable to invoke {}", script.display()))?; + if !status.success() { + bail!("{} failed with exit status {status}", script.display()); + } + Ok(()) +} + +fn run_disable_caddy_script(config: &Config) -> Result<()> { + let script = runtime_script_path(config, DISABLE_CADDY_SCRIPT)?; + let status = Command::new("/bin/bash") + .arg(&script) + .status() + .with_context(|| format!("Unable to invoke {}", script.display()))?; + if !status.success() { + bail!("{} failed with exit status {status}", script.display()); + } + Ok(()) +} + +fn runtime_script_path(config: &Config, script_name: &str) -> Result { + let path = Path::new(&config.lqos_directory).join(script_name); + if !path.exists() { + bail!( + "Expected LibreQoS runtime helper {} to exist.", + path.display() + ); + } + Ok(path) +} + +fn write_managed_caddyfile(config: &Config, preferred_host: Option<&str>) -> Result<()> { + let ssl = config + .ssl + .as_ref() + .filter(|ssl| ssl.enabled) + .ok_or_else(|| anyhow::anyhow!("SSL is not enabled in the LibreQoS config."))?; + let plan = build_runtime_plan(ssl.external_hostname.clone(), preferred_host); + let caddyfile = render_managed_caddyfile(&plan); + let path = Path::new(CADDYFILE_PATH); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Unable to create {}", parent.display()))?; + } + fs::write(path, caddyfile).with_context(|| format!("Unable to write {CADDYFILE_PATH}"))?; + Ok(()) +} + +fn read_existing_caddyfile() -> Result> { + let path = Path::new(CADDYFILE_PATH); + if !path.exists() { + return Ok(None); + } + fs::read_to_string(path) + .map(Some) + .with_context(|| format!("Unable to read {CADDYFILE_PATH}")) +} + +fn restore_caddyfile(contents: &Option) -> Result<()> { + let Some(contents) = contents else { + return Ok(()); + }; + fs::write(CADDYFILE_PATH, contents) + .with_context(|| format!("Unable to restore {CADDYFILE_PATH}")) +} + +fn render_managed_caddyfile(plan: &SslRuntimePlan) -> String { + let tls_block = if plan.using_internal_ca { + "\n tls internal" + } else { + "" + }; + + format!( + "\ +{{\n admin off\n}}\n\n\ +{site_address} {{{tls_block}\n encode zstd gzip\n handle /api/v1 {{\n redir /api/v1/ 308\n }}\n handle_path /api/v1/* {{\n reverse_proxy {api_upstream}\n }}\n reverse_proxy {web_upstream}\n}}\n", + site_address = plan.caddy_site_address, + tls_block = tls_block, + api_upstream = API_UPSTREAM, + web_upstream = WEB_UPSTREAM, + ) +} + +fn enable_runtime_command() -> &'static str { + "systemctl enable caddy.service && systemctl restart caddy.service && systemctl restart lqosd.service" +} + +fn enable_setup_command() -> &'static str { + "systemctl enable caddy.service && systemctl restart caddy.service" +} + +fn disable_runtime_command() -> &'static str { + "systemctl stop caddy.service || true; systemctl disable caddy.service || true; systemctl restart lqosd.service" +} + +fn schedule_delayed_runtime_switch(command: &str) -> Result<()> { + let mut child = Command::new(DELAYED_SWITCH_SHELL) + .args(["-lc", &format!("sleep 2; {command}")]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("Unable to spawn delayed runtime switch helper")?; + let _ = child.stdin.take(); + Ok(()) +} + +fn run_shell_command_now(command: &str) -> Result<()> { + let status = Command::new(DELAYED_SWITCH_SHELL) + .args(["-lc", command]) + .status() + .with_context(|| format!("Unable to invoke {DELAYED_SWITCH_SHELL}"))?; + if !status.success() { + bail!("Command failed with exit status {status}: {command}"); + } + Ok(()) +} + +fn caddy_binary_available() -> bool { + Command::new("caddy") + .arg("version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(test)] +mod tests { + use super::{ + API_UPSTREAM, RUNTIME_DIRECT_LISTEN, RUNTIME_SECURE_LISTEN, WEB_UPSTREAM, + apply_ssl_to_config, build_runtime_plan, disable_ssl_in_config, normalize_preferred_host, + render_managed_caddyfile, + }; + use lqos_config::{Config, normalize_external_hostname}; + + #[test] + fn blank_external_hostname_falls_back_to_internal_ca() { + let plan = build_runtime_plan(normalize_external_hostname(" ").unwrap(), None); + assert!(plan.using_internal_ca); + assert!(plan.target_url.starts_with("https://")); + assert!(plan.target_url.ends_with('/')); + } + + #[test] + fn managed_caddyfile_proxies_api_prefix_and_web_ui() { + let plan = build_runtime_plan(Some("libreqos.example.com".to_string()), None); + let caddyfile = render_managed_caddyfile(&plan); + let expected = format!( + "{{\n admin off\n}}\n\nlibreqos.example.com {{\n encode zstd gzip\n handle /api/v1 {{\n redir /api/v1/ 308\n }}\n handle_path /api/v1/* {{\n reverse_proxy {API_UPSTREAM}\n }}\n reverse_proxy {WEB_UPSTREAM}\n}}\n" + ); + assert_eq!(caddyfile, expected); + } + + #[test] + fn internal_ca_caddyfile_adds_tls_internal() { + let plan = build_runtime_plan(None, None); + let caddyfile = render_managed_caddyfile(&plan); + assert!(caddyfile.contains("tls internal")); + } + + #[test] + fn external_hostname_rejects_scheme() { + let error = normalize_external_hostname("https://libreqos.example.com") + .expect_err("hostname with scheme should fail"); + assert!(error.to_string().contains("hostname only")); + } + + #[test] + fn malformed_preferred_host_is_rejected() { + assert_eq!(normalize_preferred_host(Some("bad:port")), None); + assert_eq!(normalize_preferred_host(Some("bad/path")), None); + } + + #[test] + fn internal_ca_plan_prefers_current_request_host() { + let plan = build_runtime_plan(None, Some("setup.libreqos.test:9123")); + assert_eq!(plan.target_url, "https://setup.libreqos.test/"); + assert_eq!(plan.caddy_site_address, "https://setup.libreqos.test"); + } + + #[test] + fn enabling_ssl_preserves_existing_listener_for_disable() { + let mut config = Config { + webserver_listen: Some("192.0.2.50:9443".to_string()), + ..Config::default() + }; + let plan = apply_ssl_to_config(&mut config, Some("libreqos.example.com".to_string()), None) + .expect("ssl config should apply"); + + assert_eq!(plan.target_url, "https://libreqos.example.com/"); + assert_eq!( + config.webserver_listen.as_deref(), + Some(RUNTIME_SECURE_LISTEN) + ); + assert_eq!( + config + .ssl + .as_ref() + .and_then(|ssl| ssl.previous_webserver_listen.as_deref()), + Some("192.0.2.50:9443") + ); + } + + #[test] + fn enabling_ssl_captures_default_direct_listener_when_unset() { + let mut config = Config::default(); + + apply_ssl_to_config(&mut config, Some("libreqos.example.com".to_string()), None) + .expect("ssl config should apply"); + + assert_eq!( + config + .ssl + .as_ref() + .and_then(|ssl| ssl.previous_webserver_listen.as_deref()), + Some(RUNTIME_DIRECT_LISTEN) + ); + } + + #[test] + fn disabling_ssl_restores_previous_listener() { + let mut config = Config { + webserver_listen: Some(RUNTIME_SECURE_LISTEN.to_string()), + ..Config::default() + }; + apply_ssl_to_config(&mut config, Some("libreqos.example.com".to_string()), None) + .expect("ssl config should apply"); + config + .ssl + .as_mut() + .expect("ssl config should exist") + .previous_webserver_listen = Some("192.0.2.60:9000".to_string()); + + disable_ssl_in_config(&mut config); + + assert_eq!(config.webserver_listen.as_deref(), Some("192.0.2.60:9000")); + assert!( + !config + .ssl + .as_ref() + .expect("ssl config should still exist") + .enabled + ); + } +} diff --git a/src/rust/lqos_setup/src/web.rs b/src/rust/lqos_setup/src/web.rs index e2d5f39a..bd5ec63b 100644 --- a/src/rust/lqos_setup/src/web.rs +++ b/src/rust/lqos_setup/src/web.rs @@ -8,7 +8,7 @@ use crate::{ use anyhow::{Context, Result, bail}; use axum::{ Form, Router, - extract::Query, + extract::{Host, Query}, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, @@ -47,6 +47,8 @@ struct SaveForm { internet_vlan: Option, network_vlan: Option, allow_subnets: String, + enable_ssl: Option, + external_hostname: Option, } #[derive(Debug, Deserialize)] @@ -58,6 +60,8 @@ struct HotfixForm { struct PendingForm { token: String, operation_id: String, + enable_ssl: Option, + external_hostname: Option, } pub(crate) fn run() -> Result<()> { @@ -100,15 +104,15 @@ async fn root() -> Redirect { Redirect::temporary(target) } -async fn setup_page(Query(query): Query) -> Response { +async fn setup_page(Host(_host): Host, Query(query): Query) -> Response { match render_setup_page(query.token.as_deref(), query.notice.as_deref(), None) { Ok(html) => Html(html).into_response(), Err(err) => error_response(StatusCode::FORBIDDEN, &err.to_string()), } } -async fn completed_page() -> Response { - match render_completed_page() { +async fn completed_page(Host(host): Host) -> Response { + match render_completed_page(&host) { Ok(html) => Html(html).into_response(), Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), } @@ -163,7 +167,7 @@ async fn install_hotfix(Form(form): Form) -> Response { } } -async fn save_setup(Form(form): Form) -> Response { +async fn save_setup(Host(host): Host, Form(form): Form) -> Response { if let Err(err) = ensure_valid_setup_token(&form.token) { return error_response(StatusCode::FORBIDDEN, &err.to_string()); } @@ -192,7 +196,12 @@ async fn save_setup(Form(form): Form) -> Response { match setup_actions::prepare_commit() { Ok(CommitOutcome::Complete(success)) => { - match finalize_success_html(&success.config, success.event_log) { + match finalize_success_html( + &success.config, + success.event_log, + ssl_request(&form), + Some(&host), + ) { Ok(html) => Html(html).into_response(), Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), } @@ -201,18 +210,24 @@ async fn save_setup(Form(form): Form) -> Response { &form.token, &pending.operation_id, &pending.prompt, + ssl_request(&form), )) .into_response(), Err(err) => error_response(StatusCode::BAD_REQUEST, &err.to_string()), } } -async fn confirm_setup(Form(form): Form) -> Response { +async fn confirm_setup(Host(host): Host, Form(form): Form) -> Response { if let Err(err) = ensure_valid_setup_token(&form.token) { return error_response(StatusCode::FORBIDDEN, &err.to_string()); } match setup_actions::confirm_pending_commit(&form.operation_id) { - Ok(success) => match finalize_success_html(&success.config, success.event_log) { + Ok(success) => match finalize_success_html( + &success.config, + success.event_log, + ssl_request_from_pending(&form), + Some(&host), + ) { Ok(html) => Html(html).into_response(), Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), }, @@ -404,6 +419,14 @@ fn render_setup_page( "", escape_html(&config.allow_subnets.join("\n")) )); + html.push_str("
"); + html.push_str("

Optional HTTPS with Caddy

"); + html.push_str("

Use HTTPS if you want the LibreQoS WebUI to open with a lock icon instead of plain HTTP. This is optional.

"); + html.push_str("

If you enter a public hostname, Caddy will ask Let's Encrypt for a browser-trusted certificate. If you leave the hostname blank, Caddy will secure the box by management IP with a local certificate. Browsers will warn until you trust that local root certificate on the operator computers that use this box.

"); + html.push_str(""); + html.push_str(""); + html.push_str("

Leave the hostname blank if staff will reach LibreQoS by IP address.

"); + html.push_str("
"); html.push_str( "", ); @@ -492,6 +515,23 @@ fn parse_vlan(raw: Option<&str>) -> Result { .with_context(|| format!("Invalid VLAN value: {value}")) } +fn ssl_request(form: &SaveForm) -> Option { + ssl_request_value(&form.enable_ssl, form.external_hostname.as_deref()) +} + +fn ssl_request_from_pending(form: &PendingForm) -> Option { + ssl_request_value(&form.enable_ssl, form.external_hostname.as_deref()) +} + +fn ssl_request_value( + enable_ssl: &Option, + external_hostname: Option<&str>, +) -> Option { + enable_ssl + .is_some() + .then(|| external_hostname.unwrap_or("").trim().to_string()) +} + fn resolve_requested_bridge_mode(requested_mode: &str, allow_legacy_xdp: bool) -> Result<&str> { match requested_mode { "single" | "linux" => Ok(requested_mode), @@ -518,7 +558,12 @@ fn hidden_token(html: &mut String, token: &str) { ); } -fn render_pending_page(token: &str, operation_id: &str, prompt: &str) -> String { +fn render_pending_page( + token: &str, + operation_id: &str, + prompt: &str, + ssl_request: Option, +) -> String { let mut html = page_shell("Confirm Network Change"); html.push_str("
"); html.push_str("

Confirm Network Change

"); @@ -526,6 +571,17 @@ fn render_pending_page(token: &str, operation_id: &str, prompt: &str) -> String "
{}
", escape_html(prompt) )); + if let Some(external_hostname) = ssl_request.as_deref() { + if external_hostname.is_empty() { + html.push_str("

HTTPS with Caddy will also be enabled after this network change. LibreQoS will use a local certificate for the address you use to reach this setup page.

"); + } else { + let _ = write!( + html, + "

HTTPS with Caddy will also be enabled after this network change using hostname {}.

", + escape_html(external_hostname) + ); + } + } html.push_str("
"); html.push_str("
"); hidden_token(&mut html, token); @@ -534,6 +590,14 @@ fn render_pending_page(token: &str, operation_id: &str, prompt: &str) -> String "", escape_html(operation_id) ); + if let Some(external_hostname) = ssl_request.as_deref() { + html.push_str(""); + let _ = write!( + html, + "", + escape_html(external_hostname) + ); + } html.push_str("
"); html.push_str("
"); hidden_token(&mut html, token); @@ -550,8 +614,21 @@ fn render_pending_page(token: &str, operation_id: &str, prompt: &str) -> String fn finalize_success_html( config: &lqos_config::Config, mut event_log: Vec, + ssl_request: Option, + preferred_host: Option<&str>, ) -> Result { - setup_actions::persist_setup_success(config, &mut event_log)?; + let ssl_outcome = if let Some(external_hostname) = ssl_request { + let outcome = + lqos_setup::ssl::enable_setup_ssl(config, Some(external_hostname), preferred_host)?; + event_log.push(outcome.message.clone()); + Some(outcome) + } else { + None + }; + let final_config = lqos_config::load_config() + .map(|config| (*config).clone()) + .unwrap_or_else(|_| config.clone()); + setup_actions::persist_setup_success(&final_config, &mut event_log)?; let handoff_notice = match service_handoff::schedule_runtime_handoff() { Ok(notice) => { event_log.push(notice.message.clone()); @@ -575,22 +652,48 @@ fn finalize_success_html( "
Show setup actions
{}
", escape_html(&report) )); + if let Some(outcome) = &ssl_outcome { + html.push_str("
"); + let _ = write!( + html, + "HTTPS URL: {}", + escape_html(&outcome.target_url), + escape_html(&outcome.target_url) + ); + if let Some(ca_path) = outcome.internal_ca_root_certificate.as_deref() { + let _ = write!( + html, + "

If the browser warns about the certificate, trust the Caddy local root certificate from {} on the operator computers that manage this node.

", + escape_html(ca_path) + ); + } + html.push_str("
"); + } if handoff_notice .as_ref() .is_some_and(|notice| notice.automatic) { html.push_str("

LibreQoS runtime services are starting now. This setup page may stop responding during the handoff. Use the link below after the handoff if you want to load the normal runtime UI.

"); } - html.push_str( - "", + let open_target = ssl_outcome + .as_ref() + .map(|outcome| outcome.target_url.clone()) + .unwrap_or_else(|| runtime_access_url(&final_config, preferred_host)); + let _ = write!( + html, + "", + escape_html(&open_target) ); html.push_str("
"); Ok(html) } -fn render_completed_page() -> Result { +fn render_completed_page(preferred_host: &str) -> Result { let snapshot = bootstrap::status_snapshot()?; let report = bootstrap::load_setup_completion_report().unwrap_or_default(); + let open_target = lqos_config::load_config() + .map(|config| runtime_access_url(config.as_ref(), Some(preferred_host))) + .unwrap_or_else(|_| "/".to_string()); let mut html = page_shell("Setup Complete"); html.push_str("

Setup Saved

"); html.push_str(&format!( @@ -604,13 +707,19 @@ fn render_completed_page() -> Result { )); } html.push_str("

Use the button below after the handoff if you want to load the normal runtime UI.

"); - html.push_str( - "", + let _ = write!( + html, + "", + escape_html(&open_target) ); html.push_str("
"); Ok(html) } +fn runtime_access_url(config: &lqos_config::Config, preferred_host: Option<&str>) -> String { + lqos_setup::ssl::ssl_status(config, preferred_host).target_url +} + fn page_shell(title: &str) -> String { format!( "{}", @@ -649,6 +758,10 @@ fn base_css() -> &'static str { .busy-bar{height:12px;border-radius:999px;background:linear-gradient(90deg,var(--lqos-primary) 0%,#14b8a6 30%,#d1fae5 50%,#14b8a6 70%,var(--lqos-primary) 100%);background-size:200% 100%;animation:busy-slide 1.2s linear infinite;margin-bottom:14px}\ @keyframes busy-slide{0%{background-position:200% 0}100%{background-position:-200% 0}}\ .validation-message{margin:10px 0 0;color:#b91c1c;font-weight:700}\ + .ssl-setup-card{margin-top:18px;padding:16px;border-radius:14px;background:rgba(37,99,235,.06);border:1px solid rgba(37,99,235,.14)}\ + .ssl-setup-card h3{margin:0 0 10px;font-size:1.05rem}\ + .checkbox-row{display:flex;align-items:center;gap:10px;font-weight:700}\ + .checkbox-row input{width:auto;margin:0}\ code{background:#eef2f7;padding:2px 6px;border-radius:6px}" } @@ -694,7 +807,8 @@ fn selected(value: bool) -> &'static str { #[cfg(test)] mod tests { - use super::resolve_requested_bridge_mode; + use super::{render_pending_page, resolve_requested_bridge_mode, runtime_access_url}; + use lqos_config::{Config, SslConfig}; #[test] fn xdp_mode_is_preserved_for_existing_xdp_config() { @@ -706,4 +820,56 @@ mod tests { let error = resolve_requested_bridge_mode("xdp", false).unwrap_err(); assert!(error.to_string().contains("Unsupported bridge mode")); } + + #[test] + fn pending_page_preserves_ssl_request_fields() { + let html = render_pending_page( + "token-1", + "op-1", + "pending change", + Some("edge.libreqos.test".to_string()), + ); + + assert!(html.contains("HTTPS with Caddy will also be enabled")); + assert!(html.contains("name=\"enable_ssl\" value=\"on\"")); + assert!(html.contains("name=\"external_hostname\" value=\"edge.libreqos.test\"")); + } + + #[test] + fn runtime_access_url_uses_request_host_for_internal_ca_setup() { + let config = Config { + webserver_listen: Some("127.0.0.1:9123".to_string()), + ssl: Some(SslConfig { + enabled: true, + external_hostname: None, + managed_by_libreqos: true, + previous_webserver_listen: Some("0.0.0.0:9123".to_string()), + }), + ..Config::default() + }; + + assert_eq!( + runtime_access_url(&config, Some("setup.libreqos.test:9123")), + "https://setup.libreqos.test/" + ); + } + + #[test] + fn runtime_access_url_keeps_direct_http_when_ssl_is_disabled() { + let config = Config { + webserver_listen: Some("0.0.0.0:9123".to_string()), + ssl: Some(SslConfig { + enabled: false, + external_hostname: None, + managed_by_libreqos: true, + previous_webserver_listen: Some("0.0.0.0:9123".to_string()), + }), + ..Config::default() + }; + + assert_eq!( + runtime_access_url(&config, Some("setup.libreqos.test:9123")), + "http://setup.libreqos.test:9123/" + ); + } } diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index 02ed40ba..4f894e5b 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -23,6 +23,7 @@ lqos_heimdall = { path = "../lqos_heimdall" } lqos_stormguard = { path = "../lqos_stormguard" } lqos_probe = { path = "../lqos_probe" } lqos_netplan_helper = { path = "../lqos_netplan_helper" } +lqos_setup = { path = "../lqos_setup" } tokio = { version = "1", features = [ "full" ] } once_cell = { workspace = true} lqos_bus = { path = "../lqos_bus" } diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index d5b4afef..54331dbd 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -18,6 +18,7 @@ asn_analysis.js lts_trial.js lts_trial_setup.js config_general.js +config_ssl.js config_rtt.js config_tuning.js config_queues.js diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index cd677401..81b192fb 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -37,6 +37,9 @@ function ensureOptionalConfigSections(config) { if (!config.topology || typeof config.topology !== "object") { config.topology = {}; } + if (!config.ssl || typeof config.ssl !== "object") { + config.ssl = {}; + } if (!config.sonar_integration || typeof config.sonar_integration !== "object") { config.sonar_integration = {}; @@ -67,6 +70,13 @@ function ensureOptionalConfigSections(config) { } const topology = config.topology; + const ssl = config.ssl; + if (typeof ssl.enabled !== "boolean") ssl.enabled = false; + if (typeof ssl.managed_by_libreqos !== "boolean") ssl.managed_by_libreqos = false; + if (typeof ssl.external_hostname !== "string" && ssl.external_hostname !== null) { + ssl.external_hostname = null; + } + const normalizeMode = (mode) => { if (typeof mode !== "string") { return ""; @@ -630,6 +640,7 @@ export function validNodeList(network_json) { export function renderConfigMenu(currentPage) { const menuItems = [ { href: "config_general.html", icon: "fa-server", text: "General", id: "general" }, + { href: "config_ssl.html", icon: "fa-lock", text: "SSL Setup", id: "ssl" }, { href: "config_rtt.html", icon: "fa-stopwatch", text: "RTT Thresholds", id: "rtt" }, { href: "config_tuning.html", icon: "fa-warning", text: "Tuning", id: "tuning" }, { href: "config_interface.html", icon: "fa-chain", text: "Bridge & Interface Mode", id: "interface" }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_general.js b/src/rust/lqosd/src/node_manager/js_build/src/config_general.js index 2196501b..357a32ff 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_general.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_general.js @@ -216,8 +216,14 @@ function updateConfig() { const selectedProfileId = selectedQooProfileId(); window.config.qoo_profile_id = selectedProfileId ? selectedProfileId : null; - const webserverListen = document.getElementById("webserverListen").value.trim(); - window.config.webserver_listen = webserverListen ? webserverListen : null; + const sslEnabled = !!window.config?.ssl?.enabled; + const webserverListenInput = document.getElementById("webserverListen"); + const webserverListen = webserverListenInput.value.trim(); + if (sslEnabled) { + window.config.webserver_listen = "127.0.0.1:9123"; + } else { + window.config.webserver_listen = webserverListen ? webserverListen : null; + } } // Render the configuration menu @@ -242,8 +248,13 @@ loadConfig(() => { } // Optional fields with nullish coalescing + const sslEnabled = !!window.config?.ssl?.enabled; document.getElementById("disableWebserver").checked = window.config.disable_webserver ?? false; - document.getElementById("webserverListen").value = window.config.webserver_listen ?? ""; + document.getElementById("webserverListen").value = sslEnabled + ? "127.0.0.1:9123" + : (window.config.webserver_listen ?? ""); + document.getElementById("webserverListen").disabled = sslEnabled; + document.getElementById("sslWebserverNotice").classList.toggle("d-none", !sslEnabled); document.getElementById("disableIcmpPing").checked = window.config.disable_icmp_ping ?? false; document.getElementById("excludeEfficiencyCores").checked = window.config.exclude_efficiency_cores ?? true; document.getElementById("enableCircuitHeatmaps").checked = window.config.enable_circuit_heatmaps ?? true; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_ssl.js b/src/rust/lqosd/src/node_manager/js_build/src/config_ssl.js new file mode 100644 index 00000000..274edd03 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_ssl.js @@ -0,0 +1,151 @@ +import {loadConfig, renderConfigMenu} from "./config/config_helper"; + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function setResult(kind, message) { + const holder = document.getElementById("sslResult"); + if (!holder) return; + holder.className = `alert alert-${kind} mt-3`; + holder.classList.remove("d-none"); + holder.innerHTML = message; + holder.setAttribute("tabindex", "-1"); + holder.focus(); +} + +function setStatus(kind, message) { + const holder = document.getElementById("sslStatusAlert"); + if (!holder) return; + holder.className = `alert alert-${kind} mb-3`; + holder.innerHTML = message; +} + +function hostnameValue() { + return String(document.getElementById("externalHostname")?.value || "").trim(); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + ...options, + }); + + if (!response.ok) { + const detail = (await response.text().catch(() => "")).trim(); + throw new Error(detail || `Request failed with HTTP ${response.status}.`); + } + + return response.json(); +} + +function renderStatus(status) { + const ssl = window.config?.ssl || {}; + const hostnameInput = document.getElementById("externalHostname"); + if (hostnameInput && !hostnameInput.value.trim()) { + hostnameInput.value = ssl.external_hostname || ""; + } + + const webserverBlocked = !!window.config?.disable_webserver; + document.getElementById("sslWebserverBlocked")?.classList.toggle("d-none", !webserverBlocked); + document.getElementById("setupSslButton").disabled = webserverBlocked; + + document.getElementById("sslTargetUrl").innerHTML = status?.target_url + ? `${escapeHtml(status.target_url)}` + : "-"; + document.getElementById("sslCaddyInstalled").textContent = status?.caddy_installed ? "Yes" : "No"; + document.getElementById("sslCaddyfilePresent").textContent = status?.caddyfile_present ? "Present" : "Not present"; + document.getElementById("sslModeLabel").textContent = status?.using_internal_ca + ? "Local certificate authority" + : "Let's Encrypt / public certificate"; + document.getElementById("sslCaPath").innerHTML = status?.internal_ca_root_certificate + ? `${escapeHtml(status.internal_ca_root_certificate)}` + : "Not needed"; + + if (status?.enabled) { + setStatus( + "success", + `HTTPS is enabled. LibreQoS is using ${status.using_internal_ca ? "a local certificate authority" : "a public certificate flow"}.`, + ); + } else if (status?.managed_by_libreqos) { + setStatus( + "secondary", + "HTTPS is currently disabled. LibreQoS still owns the optional Caddy setup and can turn it back on from this page.", + ); + } else { + setStatus( + "secondary", + "HTTPS is currently disabled. Use Setup SSL to install Caddy and move the WebUI behind HTTPS.", + ); + } + document.getElementById("disableSslButton").disabled = !status?.managed_by_libreqos; +} + +async function refreshStatus() { + const status = await fetchJson("/local-api/ssl/status", { method: "GET", headers: {} }); + renderStatus(status); + return status; +} + +function actionMessage(outcome) { + let html = escapeHtml(outcome.message); + if (outcome.internal_ca_root_certificate) { + html += `
Trust ${escapeHtml(outcome.internal_ca_root_certificate)} on operator workstations if the browser warns.`; + } + html += `
Open ${escapeHtml(outcome.target_url)} after the service switch.`; + return html; +} + +async function setupSsl() { + setResult("info", "Preparing HTTPS setup..."); + const outcome = await fetchJson("/local-api/ssl/setup", { + method: "POST", + body: JSON.stringify({ + external_hostname: hostnameValue() || null, + }), + }); + setResult("success", actionMessage(outcome)); +} + +async function disableSsl() { + setResult("info", "Preparing HTTPS shutdown..."); + const outcome = await fetchJson("/local-api/ssl/disable", { + method: "POST", + body: JSON.stringify({}), + }); + setResult("success", actionMessage(outcome)); +} + +renderConfigMenu("ssl"); + +loadConfig(async () => { + const hostnameInput = document.getElementById("externalHostname"); + if (hostnameInput) { + hostnameInput.value = window.config?.ssl?.external_hostname || ""; + } + + document.getElementById("refreshSslStatusButton")?.addEventListener("click", () => { + refreshStatus().catch((error) => setResult("danger", escapeHtml(error.message))); + }); + document.getElementById("setupSslButton")?.addEventListener("click", () => { + setupSsl().catch((error) => setResult("danger", escapeHtml(error.message))); + }); + document.getElementById("disableSslButton")?.addEventListener("click", () => { + disableSsl().catch((error) => setResult("danger", escapeHtml(error.message))); + }); + + try { + await refreshStatus(); + } catch (error) { + setStatus("danger", escapeHtml(error.message)); + } +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index d90a7d05..0ce1b834 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -887,17 +887,13 @@ function initObserveShapeToggle() { } function setupDynamicUrls() { - // Get the current host and protocol from the browser - const currentHost = window.location.hostname; - const currentProtocol = window.location.protocol; - - // Construct API URL (port 9122) - // The Swagger UI lives at /api-docs/ (dash, trailing slash) - const apiUrl = `${currentProtocol}//${currentHost}:9122/api-docs/`; - + const apiUrl = window.location.protocol === "https:" + ? "/api/v1/api-docs/" + : `${window.location.protocol}//${window.location.hostname}:9122/api-docs/`; + // Construct Chat URL (port 9121) - const chatUrl = `${currentProtocol}//${currentHost}:9121/`; - + const chatUrl = "chatbot.html"; + // Update API link only if it has the placeholder const apiLink = document.getElementById('apiLink'); if (apiLink) { @@ -922,8 +918,7 @@ function setupDynamicUrls() { } else { const hrefAttr = chatLink.getAttribute('href'); if (hrefAttr === '%%CHAT_URL%%' || !hrefAttr) { - // Prefer embedded chatbot page - chatLink.href = 'chatbot.html'; + chatLink.href = chatUrl; } } } diff --git a/src/rust/lqosd/src/node_manager/local_api.rs b/src/rust/lqosd/src/node_manager/local_api.rs index 91fb4997..af0d02ad 100644 --- a/src/rust/lqosd/src/node_manager/local_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api.rs @@ -24,6 +24,7 @@ pub(crate) mod scheduler; pub(crate) mod search; pub(crate) mod shaped_device_api; pub(crate) mod shaped_devices_page; +pub(crate) mod ssl; pub(crate) mod throughput_attribution_debug; pub(crate) mod topology_manager; pub(crate) mod topology_probes; @@ -56,6 +57,9 @@ pub fn local_api(shaper_query: tokio::sync::mpsc::Sender) -> post(network_mode::retry_shaping), ) .route("/config/cobrand", post(config::upload_cobrand)) + .route("/ssl/status", get(ssl::status)) + .route("/ssl/setup", post(ssl::setup)) + .route("/ssl/disable", post(ssl::disable)) .with_state(network_mode::NetworkModeApiState::default()) .layer(Extension(shaper_query)) .layer(CorsLayer::very_permissive()) diff --git a/src/rust/lqosd/src/node_manager/local_api/ssl.rs b/src/rust/lqosd/src/node_manager/local_api/ssl.rs new file mode 100644 index 00000000..d495c742 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/ssl.rs @@ -0,0 +1,55 @@ +//! Local authenticated HTTPS/Caddy setup endpoints for the node manager. + +use crate::node_manager::auth::LoginResult; +use axum::{Extension, Json, extract::Host, http::StatusCode}; +use lqos_setup::ssl::{SslActionOutcome, SslStatus}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct SetupSslRequest { + external_hostname: Option, +} + +fn ensure_admin(login: LoginResult) -> Result<(), StatusCode> { + if login == LoginResult::Admin { + Ok(()) + } else { + Err(StatusCode::FORBIDDEN) + } +} + +/// Returns the current HTTPS/Caddy state for the running LibreQoS node. +pub(crate) async fn status( + Extension(login): Extension, + Host(host): Host, +) -> Result, StatusCode> { + ensure_admin(login)?; + let config = lqos_config::load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(lqos_setup::ssl::ssl_status( + config.as_ref(), + Some(&host), + ))) +} + +/// Queues HTTPS enablement for the running LibreQoS node. +pub(crate) async fn setup( + Extension(login): Extension, + Host(host): Host, + Json(request): Json, +) -> Result, (StatusCode, String)> { + ensure_admin(login).map_err(|status| (status, "Administrator access is required.".into()))?; + lqos_setup::ssl::enable_runtime_ssl(request.external_hostname, Some(&host)) + .map(Json) + .map_err(|err| (StatusCode::BAD_REQUEST, err.to_string())) +} + +/// Queues HTTPS shutdown and restores the direct WebUI listener. +pub(crate) async fn disable( + Extension(login): Extension, + Host(host): Host, +) -> Result, (StatusCode, String)> { + ensure_admin(login).map_err(|status| (status, "Administrator access is required.".into()))?; + lqos_setup::ssl::disable_runtime_ssl(Some(&host)) + .map(Json) + .map_err(|err| (StatusCode::BAD_REQUEST, err.to_string())) +} diff --git a/src/rust/lqosd/src/node_manager/static2/api.html b/src/rust/lqosd/src/node_manager/static2/api.html index 68912484..c0792749 100644 --- a/src/rust/lqosd/src/node_manager/static2/api.html +++ b/src/rust/lqosd/src/node_manager/static2/api.html @@ -43,8 +43,8 @@

Getting started

Open License & Services from the top menu to enter or review your key. - Once enabled, the Swagger UI is available at /api-docs - and Libby at http://<node-ip>:9121/. + On direct HTTP installs, Swagger stays on http://your-node-ip:9122/api-docs. + If optional HTTPS is enabled through Caddy, use /api/v1/api-docs on your HTTPS LibreQoS URL.

diff --git a/src/rust/lqosd/src/node_manager/static2/api_docs.html b/src/rust/lqosd/src/node_manager/static2/api_docs.html index b4a537fe..91453a22 100644 --- a/src/rust/lqosd/src/node_manager/static2/api_docs.html +++ b/src/rust/lqosd/src/node_manager/static2/api_docs.html @@ -17,13 +17,12 @@ - diff --git a/src/rust/lqosd/src/node_manager/static2/config_general.html b/src/rust/lqosd/src/node_manager/static2/config_general.html index b4d3ea55..3d9c6ddb 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_general.html +++ b/src/rust/lqosd/src/node_manager/static2/config_general.html @@ -47,6 +47,7 @@
Leave blank for default (0.0.0.0:9123)
+
HTTPS via Caddy manages this value while SSL is enabled. Use SSL Setup to change HTTPS behavior.
diff --git a/src/rust/lqosd/src/node_manager/static2/config_ssl.html b/src/rust/lqosd/src/node_manager/static2/config_ssl.html new file mode 100644 index 00000000..5f775d5a --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/config_ssl.html @@ -0,0 +1,88 @@ +
+
+ +
+
+
+
SSL Setup
+
Optional HTTPS for the LibreQoS WebUI and API docs using a local Caddy reverse proxy.
+
+
+ +
+
+
+
How This Works
+
Choose whether staff will open LibreQoS by hostname or by IP address.
+ +

+ If you enter a public hostname, Caddy will ask Let's Encrypt for a certificate that browsers already trust. + Use this when staff can reach LibreQoS by name, like libreqos.example.com. +

+ +

+ If you leave the hostname blank, Caddy will secure LibreQoS by IP address with its own local certificate authority. + This still encrypts traffic, but each operator computer must trust that local root certificate before browsers stop warning. +

+
+
+ +
+
+
Current Status
+
The buttons below install or remove the LibreQoS-managed Caddy configuration.
+ +
Loading SSL status...
+
+
Operator URL
+
-
+
Caddy Installed
+
-
+
Managed Caddyfile
+
-
+
Certificate Mode
+
-
+
Root Certificate
+
-
+
+
+
+
+
+ +
+
+
+
+
External Hostname
+
Optional. Leave blank to keep HTTPS on the management IP with a local certificate.
+ + + +
Do not include http://, https://, a path, or a port number.
+ + + + +
+
+ +
+
+
Actions
+
Setup installs Caddy if needed, writes the managed Caddyfile, and switches LibreQoS into local-only listen mode.
+ +
+ + + +
+
+
+
+
+
+ + diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index d749cc2e..5ea24fe0 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -59,6 +59,7 @@ pub(super) fn static_routes() -> Result { "executive_heatmap_download.html", "executive_heatmap_upload.html", "config_general.html", + "config_ssl.html", "config_rtt.html", "config_tuning.html", "config_queues.html",