commit a33c4822bf8149c88306e1895b4d41ef22930e87 Author: Jos Rothman Date: Wed Nov 4 21:38:49 2026 +0100 VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web interface on port 8080 (kiosk, system and network management), a restricted root helper, network changes with automatic rollback, a healthcheck timer, install/update/uninstall scripts and a pi-gen image build. Co-Authored-By: Claude Opus 5.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f353142 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test-bookworm: + # Same Python, Flask and argon2 versions as Raspberry Pi OS Bookworm. + runs-on: ubuntu-latest + container: debian:bookworm + steps: + - uses: actions/checkout@v4 + - name: Install Debian packages + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + python3 python3-flask python3-argon2 python3-websocket python3-pytest shellcheck + - name: Tests + run: python3 -m pytest -p no:cacheprovider + - name: Shellcheck + run: shellcheck -S warning install.sh update.sh uninstall.sh image/build.sh image/stage-voxip/prerun.sh image/stage-voxip/00-voxip/00-run.sh bin/voxip-kiosk-x + + test-latest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install -r requirements-dev.txt + - run: python -m pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b34d325 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +image/work/ +image/deploy/ +.dev/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ecc12b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +PY ?= .venv/bin/python +DEV := $(CURDIR)/.dev +DEV_ENV := PYTHONPATH=src VOXIP_CONFIG_DIR=$(DEV)/etc VOXIP_STATE_DIR=$(DEV)/state VOXIP_LOG_DIR=$(DEV)/log \ + VOXIP_SHARE_DIR=$(DEV)/share VOXIP_AGENT_RUN_DIR=$(DEV)/run VOXIP_BROWSER_RUN_DIR=$(DEV)/run \ + VOXIP_NETWORK_STATE_DIR=$(DEV)/net VOXIP_HELPER=/nonexistent/voxip-kiosk-helper + +.PHONY: venv test lint test-bookworm dev image clean + +venv: + python3 -m venv .venv && .venv/bin/pip install -q -r requirements-dev.txt + +test: + $(PY) -m pytest + +lint: + shellcheck -S warning install.sh update.sh uninstall.sh image/build.sh image/stage-voxip/prerun.sh \ + image/stage-voxip/00-voxip/00-run.sh bin/voxip-kiosk-x + +# The test suite against the exact Python/Flask/argon2 versions that Raspberry Pi OS Bookworm ships. +test-bookworm: + docker run --rm -v "$(CURDIR)":/src:ro debian:bookworm bash -c '\ + apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + python3 python3-flask python3-argon2 python3-websocket python3-pytest shellcheck >/dev/null && \ + cp -r /src /work && cd /work && rm -rf .venv && python3 -m pytest -p no:cacheprovider && \ + shellcheck -S warning install.sh update.sh uninstall.sh image/build.sh bin/voxip-kiosk-x' + +# Web interface on http://127.0.0.1:8080 with throwaway state in .dev/ (system actions fail gracefully). +dev: + mkdir -p $(DEV)/etc $(DEV)/state $(DEV)/log $(DEV)/run $(DEV)/net $(DEV)/share + test -f $(DEV)/etc/config.json || cp config/config.default.json $(DEV)/etc/config.json + cp VERSION $(DEV)/share/VERSION + @echo "Setupcode: zie $(DEV)/etc/setup-code (wordt aangemaakt bij de start)" + $(DEV_ENV) $(PY) -m flask --app 'voxip_kiosk.web:create_app()' run --host 127.0.0.1 --port 8080 + +image: + ./image/build.sh + +clean: + rm -rf .dev .pytest_cache image/work + find . -name __pycache__ -type d -prune -exec rm -rf {} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9ac848 --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +# VoxIP Kiosk OS + +Lichte, onderhoudbare kioskdistributie voor de Raspberry Pi 3 (Model B/B+) +op basis van Raspberry Pi OS Lite 32-bit (Bookworm of Trixie). Na het +opstarten toont Chromium fullscreen één HTTPS-pagina. Beheer gaat via de +webinterface **VoxIP Kiosk Manager** op `http://kiosk.local:8080`. + +- Geen desktopomgeving: alleen een minimale X-server plus Chromium. +- Herstelt zichzelf: browsercrashes, een vastgelopen pagina, netwerkuitval + en stroomuitval. +- Geen standaardwachtwoorden: de eerste beheerder claimt het apparaat met + een setupcode die op het scherm staat. +- Netwerkwijzigingen met automatische rollback. +- Geen klantspecifieke code: alles per klant staat in + `/etc/voxip-kiosk/config.json` en kan bij uitrol via de bootpartitie + worden meegegeven. + +De architectuur en de veiligheidskeuzes staan in +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). De testprocedure op echte +hardware staat in [`docs/HARDWARE-TEST.md`](docs/HARDWARE-TEST.md). + +## Snel aan de slag + +### Optie A — kant-en-klare image + +1. Bouw de image (zie hieronder) of gebruik een uitgebrachte + `*-voxip-kiosk-os-*.img.xz`. +2. Schrijf hem met **Raspberry Pi Imager** (*Eigen image gebruiken*) of + **balenaEtcher** naar een microSD-kaart van 16 GB of groter. +3. Optioneel, in Raspberry Pi Imager → *OS-aanpassingen*: wifi, gebruiker + met wachtwoord en/of SSH-sleutel, tijdzone. Zonder aanpassingen heeft de + image **geen** bruikbaar wachtwoord en staat SSH uit. +4. Optioneel: zet een `voxip-config.json` op de bootpartitie (zie + [Uitrol bij meerdere klanten](#uitrol-bij-meerdere-klanten)). +5. Start de Pi. Bij de eerste boot wordt de rootpartitie automatisch + vergroot tot de hele SD-kaart. Daarna verschijnt een setupscherm met de + setupcode en het adres van de beheerinterface. +6. Open `http://kiosk.local:8080` (of het getoonde IP-adres), voer de + setupcode in en kies een uniek beheerderswachtwoord. + +### Optie B — installeren op een bestaande Raspberry Pi OS Lite + +Vereist: Raspberry Pi OS Lite Bookworm of Trixie met NetworkManager (de +standaard sinds Bookworm). + +```bash +sudo apt-get install -y git +git clone voxip-kiosk-os +cd voxip-kiosk-os +sudo ./install.sh --url "https://schermen-ijsselflat.voxip.nl" --name "IJsselflat" --hostname ijsselflat +``` + +Alle opties zijn optioneel. Het script installeert de benodigde pakketten, +maakt de gebruikers `kiosk` en `voxip` aan, installeert de services en +start de kiosk. Aan het eind toont het de setupcode. + +Draaide hier eerder de InfoFlow-kiosk (`raspberry-pi/install-kiosk.sh` uit +de InfoFlow-repository)? Dan neemt `install.sh` de display-URL (inclusief +`?token=`) automatisch over en schakelt `display-kiosk.service` uit. + +### Updaten + +```bash +cd voxip-kiosk-os +sudo ./update.sh # git pull --ff-only + back-up van /etc/voxip-kiosk + install.sh --upgrade +``` + +Een update overschrijft nooit bestaande configuratie of het +beheerderswachtwoord. Back-ups staan in `/var/backups/voxip-kiosk-*.tar.gz` +(de laatste vijf). Op een apparaat met de image staat de broncode in +`/usr/src/voxip-kiosk`. Clone de repository voor updates naar een eigen map, +of pak een release uit en draai daar `sudo ./update.sh --no-pull`. + +### Verwijderen + +```bash +sudo ./uninstall.sh # programma's en services weg, configuratie blijft +sudo ./uninstall.sh --purge # ook configuratie, status, logs, netwerkprofielen en gebruikers +``` + +## Image bouwen + +Het image wordt gebouwd met [pi-gen](https://github.com/RPi-Distro/pi-gen), +de officiële bouwtool van Raspberry Pi OS. De extra stage +`image/stage-voxip` voert hetzelfde `install.sh` uit als een handmatige +installatie. + +Vereist: een Linux-buildmachine (x86_64 of arm64) met Docker en git. Op +x86_64 is ook `binfmt`/qemu nodig voor ARM-emulatie: + +```bash +sudo apt-get install -y qemu-user-static binfmt-support # alleen op x86_64 +./image/build.sh # bouwt in Docker +# of zonder Docker, op Debian/Ubuntu met de pi-gen-afhankelijkheden: +sudo ./image/build.sh --native +``` + +Resultaat: `image/deploy/-voxip-kiosk-os-.img.xz` plus een +`.sha256` en `BUILD-INFO-.txt`. + +Instelbaar via omgevingsvariabelen: + +| Variabele | Standaard | Betekenis | +|---|---|---| +| `PI_GEN_REF` | `2024-11-19-raspios-bookworm-armhf` | pi-gen-tag (32-bit) | +| `RELEASE` | `bookworm` | `bookworm` of `trixie` (moet bij `PI_GEN_REF` passen) | +| `VOXIP_DEFAULT_URL` | `https://schermen-ijsselflat.voxip.nl` | standaard kiosk-URL | +| `VOXIP_DEVICE_NAME` | `VoxIP Kiosk` | standaard apparaatnaam | +| `TARGET_HOSTNAME` | `kiosk` | hostname (`http://.local:8080`) | +| `ALLOW_DIRTY` | `0` | `1` = bouw de werkmap in plaats van de laatste commit | + +**Reproduceerbaarheid.** pi-gen staat vast op een tag en de broncode +gaat via `git archive` van één commit mee. Beide staan in `BUILD-INFO` +(ook in de image: `/usr/share/voxip-kiosk/BUILD-INFO`). De Debian- en +Raspberry Pi-pakketten komen uit de archieven zoals die op het moment van +bouwen zijn. Er bestaat geen snapshot-archief voor Raspberry Pi OS, dus +twee builds op verschillende dagen kunnen nieuwere pakketversies bevatten. +Bewaar voor een uitrol daarom de gebouwde `.img.xz` zelf. + +## Beheer + +### Webinterface — `http://.local:8080` + +| Pagina | Functies | +|---|---| +| **Dashboard** | Chromium-status, kiosk-URL, uptime, CPU, temperatuur, voeding (onderspanning), RAM, SD-kaart, IP-adressen, verbinding, versie. Grote knoppen voor *Pagina herladen*, *Chromium herstarten* en *Raspberry Pi herstarten*. | +| **Kiosk** | URL (alleen HTTPS), apparaatnaam, automatisch verversen, browserprofiel, herstart na crash, screenshot van het scherm, laatste meldingen. | +| **Systeem** | Herstarten, veilig uitschakelen, hostname, rotatie, resolutie, zoom, screensaver/DPMS, poort/HTTPS van de beheerinterface. | +| **Netwerk** | Status van ethernet en wifi, wifi scannen, wifi instellen, DHCP of statisch IP, DNS, wifi-land. Iedere wijziging moet binnen 3 minuten bevestigd worden, anders volgt een rollback. | +| **Logboek** | Logs per onderdeel (kiosk, Chromium, agent, healthcheck, beheer, netwerk, systeem). | + +*Pagina herladen* gebruikt het Chrome DevTools Protocol op localhost: de +browser blijft draaien. *Chromium herstarten* herstart alleen de browser. +*Raspberry Pi herstarten* herstart het hele systeem. + +### Commandoregel (via SSH) + +```bash +sudo voxip-kiosk-ctl status # overzicht +sudo voxip-kiosk-ctl setup-code # setupcode voor de eerste configuratie +sudo voxip-kiosk-ctl reset-password # wachtwoord vergeten: nieuwe setupcode +sudo voxip-kiosk-ctl set-url https://… # URL wijzigen +sudo voxip-kiosk-ctl reload # pagina herladen +sudo voxip-kiosk-ctl restart-browser # Chromium herstarten +sudo voxip-kiosk-ctl health # healthcheck-rapport +sudo voxip-kiosk-ctl maintenance on|off # healthcheck tijdelijk niets laten herstellen +journalctl -u voxip-kiosk-browser -u voxip-kiosk-agent -f +``` + +### Configuratie — `/etc/voxip-kiosk/config.json` + +| Sleutel | Standaard | Betekenis | +|---|---|---| +| `device_name` | `VoxIP Kiosk` | naam in beheer en op de offlinepagina | +| `url` | `https://schermen-ijsselflat.voxip.nl` | kiosk-URL, alleen HTTPS | +| `refresh_interval` | `0` | 0 = uit, anders 30–86400 seconden | +| `screen_always_on` | `true` | geen screensaver/DPMS | +| `screen_off_minutes` | `10` | scherm uit na N minuten (als `screen_always_on` uit staat) | +| `rotation` | `0` | 0, 90, 180, 270 | +| `resolution` | `auto` | `auto` of bijv. `1920x1080` / `1280x720@60` | +| `zoom` | `1.0` | 0,25–4, schaalt de pagina (grote tv's) | +| `browser_restart` | `true` | Chromium automatisch herstarten na een crash | +| `browser_profile` | `persistent` | `persistent` = cookies en offline-cache blijven bewaard, `ephemeral` = alleen in RAM | +| `admin_port` | `8080` | poort van de beheerinterface | +| `admin_https` | `false` | HTTPS met zelfondertekend certificaat | +| `admin_allow_public` | `false` | ook toegang vanaf publieke IP-adressen (afgeraden) | + +Wijzigingen via de webinterface of `voxip-kiosk-ctl` worden atomair +opgeslagen, met een `.bak` van de vorige versie. Een URL-wijziging gaat in +zonder dat Chromium herstart. Scherminstellingen gelden na een herstart van +Chromium (een vinkje in de webinterface doet dat direct). + +## Uitrol bij meerdere klanten + +Eén image voor alle klanten. Per apparaat zet je na het flashen op de +bootpartitie (`bootfs`, zichtbaar op Windows en macOS): + +- **`voxip-config.json`**: instellingen voor deze kiosk, plus optioneel + `hostname` en `wifi_country`. Een voorbeeld staat als + `voxip-config.example.json` op de bootpartitie. +- **`voxip-authorized_keys`**: openbare SSH-sleutels. SSH gaat dan aan, alleen + met sleutel-login. +- Wifi en een eigen gebruiker stel je in via Raspberry Pi Imager. + +De bestanden worden bij het opstarten verwerkt en hernoemd naar +`*.applied`, of naar `*.rejected` met de reden in `voxip-config.error.txt`. + +### InfoFlow-schermen + +Voor een InfoFlow-display gebruik je als `url` de kiosk-URL van het scherm +uit InfoFlow (*Schermen* → scherm → kiosk-URL, inclusief `?token=`). Het +token wordt in de webinterface, de CLI en de logs afgeschermd +(`?token=…ab12`). De kiosk laat een lopende pagina staan als het netwerk +wegvalt. InfoFlow toont dan zijn eigen laatst bekende inhoud, en +automatisch verversen wordt overgeslagen zolang de server niet bereikbaar +is. + +## Ontwikkelen en testen + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt +.venv/bin/python -m pytest # unit- en webtests +make lint # shellcheck (indien geïnstalleerd) +make test-bookworm # dezelfde tests in Debian Bookworm met de apt-pakketten van de Pi (Docker) +``` + +Lokaal draaien van de webinterface (zonder Pi; systeemacties falen netjes): + +```bash +make dev # http://127.0.0.1:8080 met tijdelijke mappen in .dev/ +``` + +## Probleemoplossing + +| Symptoom | Oplossing | +|---|---| +| Zwart scherm na opstarten | `sudo voxip-kiosk-ctl status`. Kijk in de journal van `voxip-kiosk-x`/`voxip-kiosk-browser`. Staat de monitor aan vóór de Pi? | +| Beheerinterface onbereikbaar na netwerkwijziging | Wacht 3 minuten (automatische rollback) of zet `voxip-network-reset` op de bootpartitie en herstart. | +| Wachtwoord vergeten | `sudo voxip-kiosk-ctl reset-password` via SSH, of verwijder `/etc/voxip-kiosk/admin.json` met de SD-kaart in een Linux-pc. | +| `kiosk.local` werkt niet | Gebruik het IP-adres (staat op het setupscherm en in je router). Windows heeft soms Bonjour/mDNS nodig. Geef iedere kiosk een unieke hostname. | +| Onderspanning in het dashboard | Gebruik de officiële 5,1 V/2,5 A-voeding. Onderspanning veroorzaakt crashes en SD-kaartcorruptie. | diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/bin/voxip-kiosk-agent b/bin/voxip-kiosk-agent new file mode 100755 index 0000000..f2adadd --- /dev/null +++ b/bin/voxip-kiosk-agent @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.agent import main # noqa: E402 + +sys.exit(main()) diff --git a/bin/voxip-kiosk-browser b/bin/voxip-kiosk-browser new file mode 100755 index 0000000..7abd68d --- /dev/null +++ b/bin/voxip-kiosk-browser @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.browser import main # noqa: E402 + +sys.exit(main()) diff --git a/bin/voxip-kiosk-ctl b/bin/voxip-kiosk-ctl new file mode 100755 index 0000000..41fd167 --- /dev/null +++ b/bin/voxip-kiosk-ctl @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.ctl import main # noqa: E402 + +sys.exit(main()) diff --git a/bin/voxip-kiosk-healthcheck b/bin/voxip-kiosk-healthcheck new file mode 100755 index 0000000..b958a06 --- /dev/null +++ b/bin/voxip-kiosk-healthcheck @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.healthcheck import main # noqa: E402 + +sys.exit(main()) diff --git a/bin/voxip-kiosk-helper b/bin/voxip-kiosk-helper new file mode 100755 index 0000000..235d40d --- /dev/null +++ b/bin/voxip-kiosk-helper @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.helper import main # noqa: E402 + +sys.exit(main()) diff --git a/bin/voxip-kiosk-manager b/bin/voxip-kiosk-manager new file mode 100755 index 0000000..0d298c3 --- /dev/null +++ b/bin/voxip-kiosk-manager @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.manager import main # noqa: E402 + +sys.exit(main()) diff --git a/bin/voxip-kiosk-x b/bin/voxip-kiosk-x new file mode 100755 index 0000000..6754eaa --- /dev/null +++ b/bin/voxip-kiosk-x @@ -0,0 +1,9 @@ +#!/bin/sh +# Starts the kiosk X server on vt7 for user "kiosk" (via Xorg.wrap). +# A fresh MIT cookie per start; Chromium reads it via XAUTHORITY. +set -eu +AUTH="${RUNTIME_DIRECTORY:-/run/voxip-kiosk-x}/Xauthority" +umask 077 +: > "$AUTH" +xauth -q -f "$AUTH" add :0 . "$(mcookie)" +exec /usr/bin/Xorg :0 vt7 -keeptty -novtswitch -nolisten tcp -nocursor -s 0 -dpms -auth "$AUTH" diff --git a/config/Xwrapper.config b/config/Xwrapper.config new file mode 100644 index 0000000..cf34913 --- /dev/null +++ b/config/Xwrapper.config @@ -0,0 +1,3 @@ +# VoxIP Kiosk OS: allow the kiosk X server to be started by a system service. +allowed_users=anybody +needs_root_rights=yes diff --git a/config/avahi-voxip.service b/config/avahi-voxip.service new file mode 100644 index 0000000..ce125ba --- /dev/null +++ b/config/avahi-voxip.service @@ -0,0 +1,10 @@ + + + + VoxIP Kiosk Manager op %h + + _http._tcp + 8080 + path=/ + + diff --git a/config/chromium/voxip-kiosk.json b/config/chromium/voxip-kiosk.json new file mode 100644 index 0000000..dbdcc53 --- /dev/null +++ b/config/chromium/voxip-kiosk.json @@ -0,0 +1,19 @@ +{ + "TranslateEnabled": false, + "PasswordManagerEnabled": false, + "AutofillAddressEnabled": false, + "AutofillCreditCardEnabled": false, + "BrowserSignin": 0, + "SyncDisabled": true, + "PromotionalTabsEnabled": false, + "MetricsReportingEnabled": false, + "SpellcheckEnabled": false, + "DefaultBrowserSettingEnabled": false, + "DefaultNotificationsSetting": 2, + "DefaultGeolocationSetting": 2, + "DownloadRestrictions": 3, + "PrintingEnabled": false, + "BackgroundModeEnabled": false, + "HideWebStoreIcon": true, + "ShowAppsShortcutInBookmarkBar": false +} diff --git a/config/config.default.json b/config/config.default.json new file mode 100644 index 0000000..c200e97 --- /dev/null +++ b/config/config.default.json @@ -0,0 +1,15 @@ +{ + "device_name": "VoxIP Kiosk", + "url": "https://schermen-ijsselflat.voxip.nl", + "refresh_interval": 0, + "screen_always_on": true, + "screen_off_minutes": 10, + "rotation": 0, + "resolution": "auto", + "zoom": 1.0, + "browser_restart": true, + "browser_profile": "persistent", + "admin_port": 8080, + "admin_https": false, + "admin_allow_public": false +} diff --git a/config/journald.conf b/config/journald.conf new file mode 100644 index 0000000..1bf3eb1 --- /dev/null +++ b/config/journald.conf @@ -0,0 +1,11 @@ +# VoxIP Kiosk OS: bounded journal, few SD-card writes. +[Journal] +Storage=persistent +SystemMaxUse=32M +SystemMaxFileSize=8M +RuntimeMaxUse=24M +MaxRetentionSec=1month +# Errors (crit and worse) are always written immediately. +SyncIntervalSec=15m +RateLimitIntervalSec=30s +RateLimitBurst=2000 diff --git a/config/logrotate b/config/logrotate new file mode 100644 index 0000000..4100229 --- /dev/null +++ b/config/logrotate @@ -0,0 +1,11 @@ +/var/log/voxip-kiosk/*.log { + weekly + rotate 8 + maxsize 2M + compress + delaycompress + missingok + notifempty + create 0640 voxip voxip + su voxip voxip +} diff --git a/config/sudoers b/config/sudoers new file mode 100644 index 0000000..b55b22f --- /dev/null +++ b/config/sudoers @@ -0,0 +1,4 @@ +# VoxIP Kiosk OS: the web interface (user voxip) may run exactly one program +# as root: the restricted helper, which validates all of its input itself. +Defaults:voxip !requiretty +voxip ALL=(root) NOPASSWD: /usr/lib/voxip-kiosk/voxip-kiosk-helper diff --git a/config/watchdog.conf b/config/watchdog.conf new file mode 100644 index 0000000..a814502 --- /dev/null +++ b/config/watchdog.conf @@ -0,0 +1,4 @@ +# VoxIP Kiosk OS: hardware watchdog. A hung kernel or PID 1 reboots the Pi. +[Manager] +RuntimeWatchdogSec=15s +RebootWatchdogSec=2min diff --git a/config/zram-generator.conf b/config/zram-generator.conf new file mode 100644 index 0000000..59ea046 --- /dev/null +++ b/config/zram-generator.conf @@ -0,0 +1,5 @@ +# VoxIP Kiosk OS: compressed swap in RAM instead of a swap file on the SD card. +[zram0] +zram-size = ram / 2 +compression-algorithm = zstd +swap-priority = 100 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..728a777 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,199 @@ +# Architectuur — VoxIP Kiosk OS + +Dit document is het architectuurvoorstel waarop de implementatie in deze +repository gebaseerd is. Het beschrijft de onderdelen, hun rechten, hoe ze +samenwerken en waarom bepaalde keuzes zijn gemaakt. + +## Doel en randvoorwaarden + +- Raspberry Pi 3 Model B/B+ (1 GB RAM), Raspberry Pi OS Lite 32-bit + (Bookworm of Trixie), 24/7 in bedrijf. +- Eén HTTPS-pagina fullscreen in Chromium, zonder desktopomgeving. +- Lokale webbeheerinterface (*VoxIP Kiosk Manager*) op poort 8080. +- Geen klantspecifieke code: alles wat per klant verschilt staat in + `/etc/voxip-kiosk/config.json` en kan bij uitrol via de bootpartitie + worden meegegeven (`voxip-config.json`). + +## Overzicht + +``` + ┌──────────────────────── Raspberry Pi ────────────────────────┐ + │ │ + HDMI ◄────────┤ voxip-kiosk-x.service Xorg :0 op vt7 (user kiosk) │ + │ ▲ │ + │ │ DISPLAY=:0 │ + │ voxip-kiosk-browser.service Chromium --kiosk (user kiosk) │ + │ ▲ CDP 127.0.0.1:9222 │ + │ │ ┌─────────────────────────────┐ │ + │ ├──┤ voxip-kiosk-agent.service │ navigatie, offline-│ + │ │ │ (user kiosk) │ pagina, verversen, │ + │ │ └─────────────────────────────┘ hang-detectie │ + │ │ │ + LAN :8080 ───►│ voxip-kiosk-manager.service Flask + gunicorn (user voxip) │ + │ │ sudo -n (één commando) │ + │ ▼ │ + │ voxip-kiosk-helper root, vaste werkwoorden, │ + │ strikte invoervalidatie │ + │ │ + │ voxip-kiosk-healthcheck.timer elke minuut, root │ + │ voxip-network-guard.service netwerk-rollback bij boot │ + │ voxip-boot-import.service config/SSH-sleutels vanaf │ + │ de bootpartitie │ + └──────────────────────────────────────────────────────────────┘ +``` + +### Processen en rechten + +| Onderdeel | Gebruiker | Taak | +|---|---|---| +| `voxip-kiosk-x` | `kiosk` (via Xorg.wrap) | Minimale X-server op vt7, zonder cursor, zonder TCP. | +| `voxip-kiosk-browser` | `kiosk` | Past rotatie/resolutie/DPMS toe (xrandr/xset), ruimt een vastgelopen profiel op en start Chromium. | +| `voxip-kiosk-agent` | `kiosk` | Bestuurt Chromium via het DevTools Protocol op localhost: offline-pagina, URL-wissel zonder herstart, periodiek verversen, hang-detectie. | +| `voxip-kiosk-manager` | `voxip` | Flask-webinterface (gunicorn, 1 worker, 4 threads). | +| `voxip-kiosk-helper` | `root` (via sudo) | Enige geprivilegieerde ingang: reboot, uitschakelen, Chromium herstarten, hostname, wifi-land, wifi-scan, netwerkconfiguratie + rollback. | +| `voxip-kiosk-healthcheck` | `root` | Bewaakt alle services en herstelt ze, met logging. | + +De webinterface draait **nooit** als root. De sudoers-regel staat +uitsluitend `/usr/lib/voxip-kiosk/voxip-kiosk-helper` toe. De helper: + +- draait met `python3 -I` (geen `PYTHONPATH`, geen user site-packages); +- kent alleen een vaste lijst werkwoorden; interne werkwoorden (zoals + `network-activate`) weigert hij als hij via sudo wordt aangeroepen; +- valideert iedere parameter opnieuw (de webinterface is niet vertrouwd); +- start alle subprocessen met een argumentenlijst, nooit via een shell; +- schrijft netwerkprofielen zelf als NetworkManager-keyfile (0600), zodat + wachtwoorden nooit op een commandoregel verschijnen. + +### Waarom X11 + twee services + +Xorg en Chromium zijn aparte services. Daardoor is *Chromium herstarten* +snel (X blijft draaien) en geeft het exitcode van Chromium direct aan +systemd door. `voxip-kiosk-browser` heeft `Requires=` en `PartOf=` op +`voxip-kiosk-x`: valt X weg, dan start de browser opnieuw mee. + +### Chromium besturen via CDP + +Chromium luistert met `--remote-debugging-address=127.0.0.1 +--remote-debugging-port=9222`, dus alleen lokaal. De agent en de +webinterface gebruiken dit om: + +- de pagina te herladen zonder de browser te herstarten; +- naar een nieuwe URL te navigeren na een configuratiewijziging; +- een screenshot te maken; +- te detecteren of de renderer nog reageert (`Runtime.evaluate`). + +### Offline-gedrag + +Chromium start altijd op een lokale startpagina +(`/usr/share/voxip-kiosk/www/start.html`). De agent controleert met een +HTTPS-verzoek (mét certificaatvalidatie) of de doel-URL bereikbaar is en +navigeert pas dan. De offline-pagina verschijnt alleen als de pagina niet +**geladen** kan worden (startpagina, Chromium-foutpagina). Valt het +netwerk weg terwijl een pagina al draait, dan blijft die pagina staan. +Zo blijven applicaties met een eigen offline-cache (zoals InfoFlow- +displays) gewoon hun laatste inhoud tonen. Automatisch verversen wordt +overgeslagen zolang de doel-URL onbereikbaar is. + +### Initiële configuratie + +Zolang er geen beheerderswachtwoord is: + +1. maakt de webinterface een eenmalige **setupcode** aan; +2. toont de kiosk op het HDMI-scherm een setuppagina met die code en de + adressen van de beheerinterface; +3. kan een wachtwoord alleen ingesteld worden met die code. + +Wie fysiek bij het scherm staat (of via SSH `voxip-kiosk-ctl setup-code` +draait) kan het apparaat dus claimen, iemand anders op het netwerk niet. + +### Netwerkwijzigingen met rollback + +1. De helper maakt een snapshot van `/etc/NetworkManager/system-connections` + en van de actieve verbindingen. +2. Hij schrijft het nieuwe profiel (`voxip-eth0` / `voxip-wlan0`, prioriteit + 100) en plant een rollback-timer (`systemd-run`, 180 s). +3. Activatie gebeurt asynchroon, zodat het HTTP-antwoord nog aankomt. +4. De beheerder bevestigt binnen 180 s (eventueel via het nieuwe adres), + anders zet de timer de snapshot terug. +5. Valt de stroom uit tijdens die periode, dan zet + `voxip-network-guard.service` de snapshot terug bij het opstarten, nog + vóór NetworkManager start. +6. Noodherstel: een leeg bestand `voxip-network-reset` op de bootpartitie + verwijdert bij de volgende boot alle VoxIP-netwerkprofielen (terug naar + DHCP). + +### Configuratie + +- `/etc/voxip-kiosk/config.json`: eigenaar `voxip`, groep `kiosk`, 0640. + Wordt atomair opgeslagen (tijdelijk bestand, `fsync`, `rename`, `fsync` + van de map) met een `.bak` van de vorige versie. Ongeldige waarden vallen + bij het laden terug op de standaard, zodat de kiosk altijd start. + Onbekende sleutels blijven bewaard (vooruit-/achteruitcompatibel). +- `/etc/voxip-kiosk/admin.json`: Argon2id-hash, 0600, alleen `voxip`. +- Updates (`install.sh --upgrade` / `update.sh`) overschrijven nooit + bestaande configuratie. + +### Betrouwbaarheid (24/7) + +- Alle services `Restart=` met `StartLimitBurst`; de healthcheck + logt een *start-limit-hit* als fout en probeert na 5 minuten opnieuw. + Er zijn dus geen stille herstartloops en geen permanente uitval. +- Hardware-watchdog (`RuntimeWatchdogSec=15s`): een vastgelopen kernel + zorgt voor een automatische reboot. +- Chromium-profiel: profielvergrendelingen en de crash-vlag worden voor + iedere start opgeruimd, zodat er na stroomuitval geen dialoog verschijnt. +- HTTP-cache van Chromium in RAM (`/run`), het profiel zelf (cookies, + localStorage/IndexedDB) blijft standaard persistent zodat offline-caches + van webapps een reboot overleven (`browser_profile: "ephemeral"` zet ook + dat in RAM). +- journald maximaal 32 MB en synchroniseert eens per 15 minuten; + `/tmp` als tmpfs; zram in plaats van swap op de SD-kaart. +- Logrotatie voor het audit-log van de beheerinterface. + +### Beveiliging + +- Argon2id (`argon2-cffi`, 64 MiB, t=3, p=2), geen standaardwachtwoord. +- Sessiecookie: `HttpOnly`, `SameSite=Strict`, `Secure` bij HTTPS, 60 min + inactiviteit, ongeldig na een wachtwoordwijziging (sessie-epoch). +- CSRF-token op iedere POST, plus controle van de `Origin`-header. +- Login-throttling per IP (exponentieel tot 15 min) en globaal. +- Beheerinterface weigert standaard verzoeken van publieke IP-adressen + (toegestaan: privé, link-local, loopback, CGNAT/VPN 100.64.0.0/10). +- Optioneel HTTPS (zelfondertekend certificaat, automatisch aangemaakt). +- SSH staat in de image standaard uit en wordt alleen ingeschakeld met + credentials uit Raspberry Pi Imager of `voxip-authorized_keys` op de + bootpartitie (dan alleen sleutel-login). +- Strikte security-headers en CSP (`default-src 'self'`). + +## Directorystructuur + +``` +voxip-kiosk-os/ +├── README.md bouw-, flash-, installatie-instructies +├── VERSION +├── Makefile test / lint / image +├── install.sh installatie op bestaande Pi OS + gebruikt door image-build +├── update.sh update met backup van configuratie +├── uninstall.sh verwijderen (--purge voor alles) +├── bin/ dunne launchers → /usr/lib/voxip-kiosk/ +├── src/voxip_kiosk/ Python-package (alle logica) +│ ├── config.py laden/valideren/atomair opslaan +│ ├── validation.py gedeelde invoervalidatie +│ ├── cdp.py Chrome DevTools Protocol-client +│ ├── browser.py Chromium-launcher (xrandr, xset, profiel) +│ ├── agent.py kiosk-agent +│ ├── healthcheck.py healthcheck +│ ├── helper.py root-helper +│ ├── netconfig.py NetworkManager-keyfiles, nmcli-parsing +│ ├── netinfo.py netwerkstatus (zonder rechten) +│ ├── sysinfo.py CPU, temperatuur, geheugen, opslag, IP +│ ├── services.py systemd- en journal-status +│ ├── ctl.py CLI voor SSH-beheer (voxip-kiosk-ctl) +│ └── web/ Flask-app, templates, statische bestanden +├── www/ lokale kioskpagina's (start, offline, setup) +├── systemd/ unit-bestanden +├── config/ standaardconfig, sudoers, journald, logrotate, … +├── image/ pi-gen image-builder (stage-voxip) +├── tests/ pytest-suite +└── docs/ architectuur, hardwaretest +``` diff --git a/docs/HARDWARE-TEST.md b/docs/HARDWARE-TEST.md new file mode 100644 index 0000000..6cf1513 --- /dev/null +++ b/docs/HARDWARE-TEST.md @@ -0,0 +1,125 @@ +# Testprocedure op Raspberry Pi 3-hardware + +De geautomatiseerde tests (`make test`, `make test-bookworm`) dekken de +configuratie, validatie, de webinterface, de helper (inclusief +netwerk-rollback), de agent en de healthcheck. Wat alleen op echte hardware +te controleren is, staat hieronder. Voer dit uit voor iedere nieuwe release +en voor elk nieuw type scherm. + +**Benodigd:** Raspberry Pi 3 Model B of B+, officiële voeding (5,1 V/2,5 A), +nieuwe microSD-kaart van 16 GB of groter, HDMI-monitor, ethernetkabel, +een wifi-netwerk, een laptop op hetzelfde netwerk. + +Noteer per stap: ✅ / ❌ + opmerking, de imageversie en het schermtype. + +## 1. Flashen en eerste boot + +- [ ] Schrijf de `.img.xz` met Raspberry Pi Imager **zonder** OS-aanpassingen. +- [ ] Eerste boot: de rootpartitie wordt vergroot (`df -h /` toont later + ongeveer de kaartgrootte). De Pi herstart zichzelf eenmaal. +- [ ] Binnen 90 s na stroom aan verschijnt het setupscherm met de setupcode, + `http://kiosk.local:8080` en het IP-adres. Er is geen muiscursor en + er zijn geen dialoogvensters. +- [ ] SSH is uit (`ssh voxipadmin@kiosk.local` wordt geweigerd), en er is + geen bruikbaar standaardwachtwoord. + +## 2. Initiële configuratie + +- [ ] `http://kiosk.local:8080` en `http://:8080` tonen de setuppagina. +- [ ] Een verkeerde setupcode wordt geweigerd. Na 5 pogingen volgt een wachttijd. +- [ ] Een zwak wachtwoord (`admin12345`) wordt geweigerd. +- [ ] Met de juiste code en een sterk wachtwoord: dashboard. Het scherm + schakelt binnen 10 s naar de kiosk-URL. +- [ ] De setupcode werkt daarna niet meer (`/setup` stuurt door). + +## 3. Kiosk + +- [ ] De pagina is fullscreen, zonder adresbalk, scrollbalk-pop-ups, + vertaalbalk of "Chromium is niet correct afgesloten". +- [ ] Na 30 minuten is het scherm niet gedimd of zwart geworden. +- [ ] **Pagina herladen** (dashboard): de pagina herlaadt binnen 2 s, zonder + zwart scherm (Chromium herstart niet). +- [ ] **Chromium herstarten**: kort zwart scherm, binnen 15 s weer de pagina. +- [ ] Een andere URL instellen (Kiosk → Instellingen): binnen 10 s getoond, + zonder herstart van Chromium (de teller *Herstarts* blijft gelijk). +- [ ] Een `http://`-URL wordt geweigerd. +- [ ] Verversinterval op *Elke minuut*: de pagina herlaadt ongeveer elke minuut + (zie Logboek → Kiosk-agent). +- [ ] Screenshot op de Kiosk-pagina toont wat er op het scherm staat. +- [ ] InfoFlow-URL met `?token=`: het token is overal gemaskeerd + (dashboard, `voxip-kiosk-ctl status`, `journalctl -u voxip-kiosk-agent`). + +## 4. Herstel + +- [ ] `sudo pkill -9 chromium`: binnen 15 s is de pagina terug. Het logboek + toont de herstart. +- [ ] `sudo pkill -9 Xorg`: X en Chromium komen binnen 20 s terug. +- [ ] Crashloop: `sudo voxip-kiosk-ctl set-url https://localhost:1/` en + daarna 7× `sudo pkill -9 chromium` binnen 2 minuten. De service komt + in de fouttoestand (start-limit). De healthcheck logt een fout en start + hem na ongeveer 5 minuten opnieuw. Er is geen eindeloze stille lus. + Zet de URL daarna terug. +- [ ] Vastgelopen pagina: open via SSH-tunnel DevTools of zet een testpagina met + `while(true){}` in. Na ±15 s herlaadt de agent, na ±30 s herstart Chromium. +- [ ] `sudo systemctl stop voxip-kiosk-agent`: de healthcheck start hem binnen 2 minuten. +- [ ] De webinterface blijft werken terwijl Chromium gestopt is + (`sudo systemctl stop voxip-kiosk-browser`). + +## 5. Netwerk + +- [ ] Netwerkkabel eruit terwijl de pagina draait: de pagina **blijft + staan** (een InfoFlow-display toont zijn laatste inhoud). Het + dashboard toont *Offline*. +- [ ] Kabel weer erin: het dashboard toont binnen 1 minuut *Online*. +- [ ] Opstarten zonder netwerk: offlinepagina met klok. Kabel erin: + automatisch naar de kiosk-URL. +- [ ] Wifi scannen toont netwerken. Wifi instellen (met kabel nog erin): + wlan0 krijgt een adres. Bevestigen werkt. +- [ ] Statisch IP op eth0 in een ander adres van hetzelfde subnet: + beheer bereikbaar op het nieuwe adres, bevestigen daar. +- [ ] **Rollback:** stel een statisch IP in een verkeerd subnet in en bevestig + niet. Na 3 minuten is het oude adres weer bereikbaar. +- [ ] **Rollback na stroomuitval:** herhaal, trek binnen 3 minuten de stroom + eruit. Na het opstarten zijn de oude instellingen actief. +- [ ] **Noodreset:** stel iets onbereikbaars in en bevestig het. Zet + `voxip-network-reset` op de bootpartitie en start: weer DHCP. + +## 6. Systeem + +- [ ] Rotatie 90° en 270° (met *direct toepassen*): beeld gedraaid, pagina + vult het scherm. +- [ ] Resolutie 1280x720: het scherm schakelt om. Een niet-ondersteunde resolutie + valt terug op automatisch. +- [ ] *Scherm altijd aan* uit, 1 minuut: na een minuut gaat het scherm uit + (DPMS). Zet het weer aan. +- [ ] Hostname wijzigen naar `test-kiosk`: `http://test-kiosk.local:8080` werkt. +- [ ] HTTPS aan: doorgestuurd naar `https://…:8080`. Na bevestiging van het + certificaat werkt inloggen. +- [ ] **Veilig uitschakelen**: de groene LED stopt met knipperen. De Pi komt pas terug na stroom eruit/erin. +- [ ] **Raspberry Pi herstarten**: na ±60 s weer kiosk + beheer. + +## 7. Betrouwbaarheid (24/7) + +- [ ] 10× stroom eruit tijdens normaal bedrijf (willekeurige momenten): steeds + zonder ingrijpen terug in de kiosk, zonder "herstellen"-melding. +- [ ] 72 uur continu draaien met verversinterval 5 min. Daarna: geheugen + < 80% (dashboard), geen onderspanning, `journalctl -p err -b` zonder + terugkerende fouten, `voxip-kiosk-ctl status` alles *Actief*. +- [ ] SD-kaartschrijfacties: `cat /proc/diskstats | grep mmcblk0` op t=0 en + na 1 uur stationair. Het verschil in geschreven sectoren (kolom 10) is + minder dan 50 MB per uur. +- [ ] Hardware-watchdog actief: `systemctl show -p RuntimeWatchdogUSec` toont `15s`. + +## 8. Uitrol en updates + +- [ ] Nieuwe kaart met `voxip-config.json` (url, device_name, hostname, + rotation) en `voxip-authorized_keys` op de bootpartitie: bij de eerste + boot toegepast, hernoemd naar `.applied`, SSH alleen met sleutel. +- [ ] Ongeldige `voxip-config.json` (http-URL): `.rejected` en + `voxip-config.error.txt` met de reden. De kiosk start met de standaardconfiguratie. +- [ ] `sudo ./update.sh` vanuit een nieuwere checkout: versie in het dashboard + verhoogd, configuratie en wachtwoord ongewijzigd, back-up in `/var/backups`. +- [ ] `sudo ./uninstall.sh --purge` op een testkaart: geen VoxIP-services of + -bestanden meer (`systemctl list-unit-files | grep voxip` is leeg). +- [ ] Installatie op een bestaande Raspberry Pi OS Lite (Bookworm) met de + InfoFlow-kiosk erop: URL overgenomen, `display-kiosk.service` uitgeschakeld. diff --git a/image/boot-README.txt b/image/boot-README.txt new file mode 100644 index 0000000..3308a7c --- /dev/null +++ b/image/boot-README.txt @@ -0,0 +1,35 @@ +VoxIP Kiosk OS — bestanden op deze bootpartitie +================================================ + +Je kunt een kiosk vooraf instellen door vóór de eerste start bestanden op +deze partitie te zetten (bijvoorbeeld direct na het flashen). Ze worden bij +het opstarten verwerkt en daarna hernoemd naar *.applied (of *.rejected bij +een fout; de reden staat dan in voxip-config.error.txt). + +voxip-config.json + Instellingen voor deze kiosk. Zie voxip-config.example.json. Toegestaan: + alle sleutels uit /etc/voxip-kiosk/config.json plus "hostname" en + "wifi_country". Voorbeeld: + + { + "device_name": "Hal begane grond", + "url": "https://schermen-klant.voxip.nl/display/hal?token=…", + "hostname": "hal-bg", + "rotation": 90 + } + +voxip-authorized_keys + Openbare SSH-sleutels (één per regel). SSH wordt dan ingeschakeld, alleen + met sleutel-login, voor de beheerder (voxipadmin of de gebruiker die je + in Raspberry Pi Imager hebt aangemaakt). + +voxip-network-reset + Leeg bestand. Verwijdert bij de volgende start alle via de + beheerinterface ingestelde netwerkprofielen (terug naar DHCP). Gebruik + dit als de kiosk na een netwerkwijziging onbereikbaar is. + +Wifi, gebruikersnaam/wachtwoord en SSH stel je het makkelijkst in met de +"OS-aanpassingen" van Raspberry Pi Imager. + +Beheerinterface: http://kiosk.local:8080 (of http://.local:8080). +De setupcode voor het eerste wachtwoord staat op het scherm. diff --git a/image/build.sh b/image/build.sh new file mode 100755 index 0000000..b034cb9 --- /dev/null +++ b/image/build.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Build a flashable VoxIP Kiosk OS image (.img.xz) with pi-gen. +# +# ./image/build.sh # build in Docker (recommended) +# sudo ./image/build.sh --native # build directly on a Debian/Ubuntu host +# +# Environment (all optional): +# PI_GEN_REF pi-gen tag/branch (32-bit armhf). Default: pinned Bookworm release. +# RELEASE bookworm | trixie (must match PI_GEN_REF). Default: bookworm. +# VOXIP_DEFAULT_URL kiosk URL baked into the default config. +# VOXIP_DEVICE_NAME default device name. +# TARGET_HOSTNAME default hostname (the admin UI is at http://.local:8080). +# ALLOW_DIRTY=1 build from the working tree instead of the committed HEAD. +# +# Output: image/deploy/-voxip-kiosk-os-.img.xz (+ .sha256) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="${WORK_DIR:-$ROOT/image/work}" +OUT="$ROOT/image/deploy" +PI_GEN_REPO="${PI_GEN_REPO:-https://github.com/RPi-Distro/pi-gen.git}" +PI_GEN_REF="${PI_GEN_REF:-2024-11-19-raspios-bookworm-armhf}" +RELEASE="${RELEASE:-bookworm}" +VOXIP_DEFAULT_URL="${VOXIP_DEFAULT_URL:-https://schermen-ijsselflat.voxip.nl}" +VOXIP_DEVICE_NAME="${VOXIP_DEVICE_NAME:-VoxIP Kiosk}" +TARGET_HOSTNAME="${TARGET_HOSTNAME:-kiosk}" +NATIVE=0 +[[ ${1:-} == "--native" ]] && NATIVE=1 +[[ ${1:-} == "-h" || ${1:-} == "--help" ]] && { sed -n '3,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } + +VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")" +command -v git >/dev/null || { echo "git is vereist" >&2; exit 1; } +if [[ $NATIVE -eq 0 ]]; then + command -v docker >/dev/null || { echo "Docker is vereist (of gebruik --native op Debian/Ubuntu)" >&2; exit 1; } +else + [[ $EUID -eq 0 ]] || { echo "--native moet met sudo" >&2; exit 1; } +fi + +echo "==> pi-gen $PI_GEN_REF ophalen" +mkdir -p "$WORK" "$OUT" +rm -rf "$WORK/pi-gen" +git clone --quiet --depth 1 --branch "$PI_GEN_REF" "$PI_GEN_REPO" "$WORK/pi-gen" +PI_GEN_COMMIT="$(git -C "$WORK/pi-gen" rev-parse HEAD)" +PIGEN="$WORK/pi-gen" + +# Do not export the plain Lite image from stage2; ours is exported after stage-voxip. +touch "$PIGEN/stage2/SKIP_IMAGES" +rm -f "$PIGEN/stage2/EXPORT_NOOBS" + +echo "==> VoxIP-stage voorbereiden" +cp -a "$ROOT/image/stage-voxip" "$PIGEN/stage-voxip" +PAYLOAD="$PIGEN/stage-voxip/00-voxip/files/voxip-kiosk" +mkdir -p "$PAYLOAD" +if [[ ${ALLOW_DIRTY:-0} == 1 ]] || ! git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then + echo " (werkmap wordt gebruikt, niet de laatste commit)" + tar -C "$ROOT" --exclude=./image/work --exclude=./image/deploy --exclude=./.git --exclude=./.venv \ + --exclude='__pycache__' --exclude=.pytest_cache -cf - . | tar -C "$PAYLOAD" -xf - + SOURCE_REV="werkmap" +else + if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then + echo " Let op: niet-gecommitte wijzigingen worden NIET meegenomen (ALLOW_DIRTY=1 om dat wel te doen)." + fi + git -C "$ROOT" archive HEAD | tar -C "$PAYLOAD" -xf - + SOURCE_REV="$(git -C "$ROOT" rev-parse HEAD)" +fi + +BUILD_DATE="$(date -u +%Y-%m-%d)" +cat > "$PIGEN/stage-voxip/00-voxip/files/build.env" < "$PAYLOAD/BUILD-INFO" < "$PIGEN/config" < Image bouwen (dit duurt 30–90 minuten)" +cd "$PIGEN" +if [[ $NATIVE -eq 1 ]]; then + ./build.sh +else + CONTINUE=0 PRESERVE_CONTAINER=0 ./build-docker.sh +fi + +shopt -s nullglob +images=("$PIGEN"/deploy/*.img.xz) +[[ ${#images[@]} -gt 0 ]] || { echo "Geen image gevonden in $PIGEN/deploy" >&2; exit 1; } +for img in "${images[@]}"; do + cp "$img" "$OUT/" + (cd "$OUT" && sha256sum "$(basename "$img")" > "$(basename "$img").sha256") + echo "==> Klaar: $OUT/$(basename "$img")" +done +cp "$PAYLOAD/BUILD-INFO" "$OUT/BUILD-INFO-$VERSION.txt" diff --git a/image/stage-voxip/00-voxip/00-run.sh b/image/stage-voxip/00-voxip/00-run.sh new file mode 100755 index 0000000..bb9328f --- /dev/null +++ b/image/stage-voxip/00-voxip/00-run.sh @@ -0,0 +1,23 @@ +#!/bin/bash -e +# Install VoxIP Kiosk OS into the image with the same install.sh that is +# used on existing Raspberry Pi OS installations. + +# shellcheck disable=SC1091 +source files/build.env + +rm -rf "${ROOTFS_DIR}/usr/src/voxip-kiosk" +install -d "${ROOTFS_DIR}/usr/src/voxip-kiosk" +cp -a files/voxip-kiosk/. "${ROOTFS_DIR}/usr/src/voxip-kiosk/" + +on_chroot << EOF_CHROOT +/usr/src/voxip-kiosk/install.sh --image --url $(printf %q "${VOXIP_DEFAULT_URL}") --name $(printf %q "${VOXIP_DEVICE_NAME}") +install -m 0644 /usr/src/voxip-kiosk/BUILD-INFO /usr/share/voxip-kiosk/BUILD-INFO +# No usable default password: unlocked only by Raspberry Pi Imager credentials. +passwd -l ${FIRST_USER_NAME} +systemctl disable ssh.service 2>/dev/null || true +apt-get clean +EOF_CHROOT + +# Instructions next to config.txt for whoever opens the boot partition. +install -m 0644 files/voxip-kiosk/image/boot-README.txt "${ROOTFS_DIR}/boot/firmware/VOXIP-LEESMIJ.txt" +install -m 0644 files/voxip-kiosk/image/voxip-config.example.json "${ROOTFS_DIR}/boot/firmware/voxip-config.example.json" diff --git a/image/stage-voxip/EXPORT_IMAGE b/image/stage-voxip/EXPORT_IMAGE new file mode 100644 index 0000000..79ec11a --- /dev/null +++ b/image/stage-voxip/EXPORT_IMAGE @@ -0,0 +1,4 @@ +IMG_SUFFIX="" +if [ "${USE_QEMU}" = "1" ]; then + export IMG_SUFFIX="${IMG_SUFFIX}-qemu" +fi diff --git a/image/stage-voxip/prerun.sh b/image/stage-voxip/prerun.sh new file mode 100755 index 0000000..70c06a3 --- /dev/null +++ b/image/stage-voxip/prerun.sh @@ -0,0 +1,5 @@ +#!/bin/bash -e +# Start from the root filesystem of the previous stage (stage2 = Raspberry Pi OS Lite). +if [ ! -d "${ROOTFS_DIR}" ]; then + copy_previous +fi diff --git a/image/voxip-config.example.json b/image/voxip-config.example.json new file mode 100644 index 0000000..0eb813e --- /dev/null +++ b/image/voxip-config.example.json @@ -0,0 +1,10 @@ +{ + "device_name": "Hal begane grond", + "url": "https://schermen-ijsselflat.voxip.nl", + "hostname": "hal-bg", + "wifi_country": "NL", + "refresh_interval": 0, + "rotation": 0, + "resolution": "auto", + "screen_always_on": true +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9e1263c --- /dev/null +++ b/install.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +# +# VoxIP Kiosk OS installer. +# +# Turns Raspberry Pi OS Lite (Bookworm/Trixie, NetworkManager) into a VoxIP +# kiosk. Also used by the image builder (inside the pi-gen chroot), so a +# flashed image and a manual install are identical. +# +# sudo ./install.sh [--url https://…] [--name "Hal begane grond"] [--hostname hal-bg] +# sudo ./install.sh --upgrade # update code, keep configuration +# ./install.sh --image … # used by image/stage-voxip (no service starts) +# +# Existing configuration in /etc/voxip-kiosk is never overwritten. + +set -euo pipefail + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="install" +URL="" +NAME="" +NEW_HOSTNAME="" +FORCE=0 + +LIB=/usr/lib/voxip-kiosk +SHARE=/usr/share/voxip-kiosk +DOC=/usr/share/doc/voxip-kiosk +ETC=/etc/voxip-kiosk +UNITS=(voxip-kiosk-x.service voxip-kiosk-browser.service voxip-kiosk-agent.service voxip-kiosk-manager.service + voxip-kiosk-healthcheck.service voxip-kiosk-healthcheck.timer voxip-network-guard.service voxip-boot-import.service) +ENABLE=(voxip-kiosk-x.service voxip-kiosk-browser.service voxip-kiosk-agent.service voxip-kiosk-manager.service + voxip-kiosk-healthcheck.timer voxip-network-guard.service voxip-boot-import.service) + +say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mLet op:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mFout:\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --url) URL="${2:?--url vereist een waarde}"; shift 2 ;; + --name) NAME="${2:?--name vereist een waarde}"; shift 2 ;; + --hostname) NEW_HOSTNAME="${2:?--hostname vereist een waarde}"; shift 2 ;; + --upgrade) MODE="upgrade"; shift ;; + --image) MODE="image"; shift ;; + --force) FORCE=1; shift ;; + -h|--help) usage 0 ;; + *) warn "Onbekende optie: $1"; usage 1 ;; + esac +done + +[[ $EUID -eq 0 ]] || die "Voer dit script uit met sudo." +[[ -f "$SRC/src/voxip_kiosk/config.py" ]] || die "Start install.sh vanuit de VoxIP Kiosk OS-repository." + +in_chroot() { + if command -v systemd-detect-virt >/dev/null 2>&1 && systemd-detect-virt --chroot --quiet; then + return 0 + fi + [[ "$(stat -c %d:%i /)" != "$(stat -c %d:%i /proc/1/root/. 2>/dev/null || echo x)" ]] +} +LIVE=1 +if [[ $MODE == image ]] || in_chroot; then LIVE=0; fi + +# --- checks --------------------------------------------------------------- +# In a subshell: os-release defines NAME and VERSION, which must not clobber ours. +# shellcheck disable=SC1091 +CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")" +case "$CODENAME" in + bookworm|trixie) ;; + *) [[ $FORCE -eq 1 ]] || die "Niet-ondersteunde release '${CODENAME:-?}'. Ondersteund: bookworm, trixie (--force om toch door te gaan)." ;; +esac + +if [[ $LIVE -eq 1 && $MODE == install ]]; then + if ! systemctl is-enabled --quiet NetworkManager.service 2>/dev/null && systemctl is-enabled --quiet dhcpcd.service 2>/dev/null; then + die "Dit systeem gebruikt dhcpcd. VoxIP Kiosk OS vereist NetworkManager (standaard sinds Bookworm). Schakel om via 'sudo raspi-config' → Advanced Options → Network Config, of gebruik de kant-en-klare image." + fi +fi + +VERSION="$(tr -d '[:space:]' < "$SRC/VERSION")" +say "VoxIP Kiosk OS $VERSION installeren (modus: $MODE)" + +# --- packages ----------------------------------------------------------------- +pkg_available() { apt-cache policy "$1" 2>/dev/null | grep -q 'Candidate: [0-9]'; } + +{ + say "Pakketten installeren" + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + CHROMIUM=chromium + if pkg_available chromium-browser && [[ $CODENAME == bookworm ]]; then CHROMIUM=chromium-browser; fi + PKGS=( + "$CHROMIUM" xserver-xorg-core xserver-xorg-input-libinput xserver-xorg-legacy x11-xserver-utils xauth + fonts-dejavu-core fonts-noto-color-emoji + python3 python3-flask python3-argon2 python3-websocket gunicorn + network-manager avahi-daemon iw rfkill iproute2 openssl logrotate sudo kbd + ) + for opt in systemd-zram-generator libraspberrypi-bin raspi-utils-core; do + if pkg_available "$opt"; then PKGS+=("$opt"); fi + done + # The "chromium" package needs its setuid sandbox where unprivileged user namespaces are unavailable. + if [[ $CHROMIUM == chromium ]] && pkg_available chromium-sandbox; then PKGS+=(chromium-sandbox); fi + apt-get install -y --no-install-recommends "${PKGS[@]}" +} + +# --- users ---------------------------------------------------------------------- +say "Gebruikers aanmaken" +getent group kiosk >/dev/null || groupadd --system kiosk +id kiosk >/dev/null 2>&1 || useradd --system --gid kiosk --home-dir /home/kiosk --create-home \ + --shell /usr/sbin/nologin --comment "VoxIP kiosk display" kiosk +for g in video render input audio; do + getent group "$g" >/dev/null && usermod -aG "$g" kiosk +done +getent group voxip >/dev/null || groupadd --system voxip +id voxip >/dev/null 2>&1 || useradd --system --gid voxip --home-dir /var/lib/voxip-kiosk --no-create-home \ + --shell /usr/sbin/nologin --comment "VoxIP Kiosk Manager" voxip +for g in systemd-journal video; do + getent group "$g" >/dev/null && usermod -aG "$g" voxip +done + +# --- application files -------------------------------------------------------------- +say "Programmabestanden installeren" +install -d -m 0755 "$LIB" "$SHARE" "$DOC" +rm -rf "$LIB/python.new" +install -d -m 0755 "$LIB/python.new" +tar -C "$SRC/src" --exclude='__pycache__' --exclude='*.pyc' -cf - voxip_kiosk | tar -C "$LIB/python.new" -xf - +chown -R root:root "$LIB/python.new" +chmod -R u=rwX,go=rX "$LIB/python.new" +python3 -m compileall -q "$LIB/python.new" >/dev/null +rm -rf "$LIB/python.old" +[[ -d "$LIB/python" ]] && mv "$LIB/python" "$LIB/python.old" +mv "$LIB/python.new" "$LIB/python" +rm -rf "$LIB/python.old" + +for bin in voxip-kiosk-agent voxip-kiosk-browser voxip-kiosk-healthcheck voxip-kiosk-helper voxip-kiosk-manager voxip-kiosk-x; do + install -m 0755 -o root -g root "$SRC/bin/$bin" "$LIB/$bin" +done +install -m 0755 -o root -g root "$SRC/bin/voxip-kiosk-ctl" /usr/local/sbin/voxip-kiosk-ctl + +rm -rf "$SHARE/www" +install -d -m 0755 "$SHARE/www" +install -m 0644 "$SRC"/www/* "$SHARE/www/" +install -m 0644 "$SRC/VERSION" "$SHARE/VERSION" +install -m 0644 "$SRC/README.md" "$DOC/README.md" +install -m 0644 "$SRC"/docs/*.md "$DOC/" + +# --- configuration ------------------------------------------------------------------- +say "Configuratie" +install -d -o voxip -g kiosk -m 2750 "$ETC" +install -d -o voxip -g voxip -m 0750 /var/lib/voxip-kiosk +install -d -o root -g root -m 0755 /var/lib/voxip-kiosk-network +install -d -o voxip -g voxip -m 0750 /var/log/voxip-kiosk + +# Migrate from InfoFlow's raspberry-pi/install-kiosk.sh: take over its URL, then disable it. +INFOFLOW_UNIT=/etc/systemd/system/display-kiosk.service +if [[ -f $INFOFLOW_UNIT ]]; then + OLD_URL="$(grep -oE 'https://[^" ]+' "$INFOFLOW_UNIT" | tail -n1 || true)" + if [[ -n $OLD_URL && -z $URL && ! -f $ETC/config.json ]]; then + URL="$OLD_URL" + say "URL overgenomen van de InfoFlow-kioskinstallatie" + fi + if [[ $LIVE -eq 1 ]]; then systemctl disable --now display-kiosk.service 2>/dev/null || true; fi + mv "$INFOFLOW_UNIT" "$INFOFLOW_UNIT.voxip-disabled" +fi + +PYRUN=(python3 -I -c) +"${PYRUN[@]}" ' +import json, os, sys +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") +from voxip_kiosk import config as c +path, default, url, name = sys.argv[1:5] +updates = {} +if url: updates["url"] = url +if name: updates["device_name"] = name +if os.path.exists(path): + if updates: + c.save_config(c.Path(path), updates) + print("Bestaande configuratie bijgewerkt:", ", ".join(updates)) + else: + print("Bestaande configuratie behouden") +else: + with open(default, encoding="utf-8") as fh: + data = json.load(fh) + data.update(c.validate(updates)) + c.validate(data) + c.atomic_write_json(c.Path(path), data) + print("Nieuwe configuratie aangemaakt") +' "$ETC/config.json" "$SRC/config/config.default.json" "$URL" "$NAME" || die "Ongeldige --url of --name." +chown voxip:kiosk "$ETC/config.json" "$ETC/config.json.bak" 2>/dev/null || true +chmod 0640 "$ETC/config.json" +touch "$ETC/.config.lock" && chown voxip:kiosk "$ETC/.config.lock" && chmod 0640 "$ETC/.config.lock" +[[ -f $ETC/admin.json ]] && chown voxip:voxip "$ETC/admin.json" && chmod 0600 "$ETC/admin.json" + +# --- system integration ---------------------------------------------------------------- +say "Systeemintegratie" +TMP_SUDOERS="$(mktemp)" +install -m 0440 "$SRC/config/sudoers" "$TMP_SUDOERS" +visudo -cqf "$TMP_SUDOERS" || die "sudoers-bestand ongeldig" +mv "$TMP_SUDOERS" /etc/sudoers.d/voxip-kiosk +chown root:root /etc/sudoers.d/voxip-kiosk + +install -D -m 0644 "$SRC/config/journald.conf" /etc/systemd/journald.conf.d/voxip-kiosk.conf +install -D -m 0644 "$SRC/config/watchdog.conf" /etc/systemd/system.conf.d/voxip-watchdog.conf +install -D -m 0644 "$SRC/config/logrotate" /etc/logrotate.d/voxip-kiosk + +if [[ -f /etc/X11/Xwrapper.config && ! -f /etc/X11/Xwrapper.config.voxip-orig ]]; then + cp -a /etc/X11/Xwrapper.config /etc/X11/Xwrapper.config.voxip-orig +fi +install -D -m 0644 "$SRC/config/Xwrapper.config" /etc/X11/Xwrapper.config + +for dir in /etc/chromium/policies/managed /etc/chromium-browser/policies/managed; do + install -D -m 0644 "$SRC/config/chromium/voxip-kiosk.json" "$dir/voxip-kiosk.json" +done + +ADMIN_PORT="$(python3 -I -c 'import sys; sys.path.insert(0, "/usr/lib/voxip-kiosk/python"); from voxip_kiosk.config import load_config; print(load_config(__import__("pathlib").Path(sys.argv[1]))["admin_port"])' "$ETC/config.json")" +install -d -m 0755 /etc/avahi/services +sed "s#8080#${ADMIN_PORT}#" "$SRC/config/avahi-voxip.service" > /etc/avahi/services/voxip-kiosk.service +chmod 0644 /etc/avahi/services/voxip-kiosk.service + +# Swap: zram in RAM instead of a swap file on the SD card (Trixie's rpi-swap already does this). +if dpkg -s systemd-zram-generator >/dev/null 2>&1 && ! dpkg -s rpi-swap >/dev/null 2>&1; then + install -m 0644 "$SRC/config/zram-generator.conf" /etc/systemd/zram-generator.conf + if dpkg -s dphys-swapfile >/dev/null 2>&1; then + if [[ $LIVE -eq 1 ]]; then dphys-swapfile swapoff 2>/dev/null || true; fi + systemctl disable dphys-swapfile.service 2>/dev/null || true + rm -f /var/swap + fi +fi + +# /tmp in RAM (default on Trixie, opt-in on Bookworm). +if [[ -f /usr/share/systemd/tmp.mount && ! -e /etc/systemd/system/tmp.mount ]] && ! systemctl is-enabled --quiet tmp.mount 2>/dev/null; then + cp /usr/share/systemd/tmp.mount /etc/systemd/system/tmp.mount + systemctl enable tmp.mount 2>/dev/null || true +fi + +# No console blanking (the kiosk runs on vt7, but keep the console awake too). +for cmdline in /boot/firmware/cmdline.txt /boot/cmdline.txt; do + if [[ -f $cmdline ]]; then + grep -q 'consoleblank=' "$cmdline" || sed -i '1 s/$/ consoleblank=0/' "$cmdline" + break + fi +done + +# A desktop display manager would fight over vt7. +for dm in lightdm.service gdm3.service sddm.service; do + if systemctl is-enabled --quiet "$dm" 2>/dev/null; then + warn "$dm wordt uitgeschakeld (de kiosk gebruikt een eigen minimale X-sessie)." + systemctl disable "$dm" 2>/dev/null || true + fi +done + +# --- services ------------------------------------------------------------------------------ +say "Services installeren" +for unit in "${UNITS[@]}"; do + install -m 0644 "$SRC/systemd/$unit" "/etc/systemd/system/$unit" +done +if [[ $LIVE -eq 1 ]]; then systemctl daemon-reload; fi +systemctl enable "${ENABLE[@]}" NetworkManager.service avahi-daemon.service >/dev/null +if [[ ! -f /var/lib/voxip-kiosk/previous-default-target ]]; then + systemctl get-default > /var/lib/voxip-kiosk/previous-default-target 2>/dev/null || true +fi +systemctl set-default multi-user.target >/dev/null + +if [[ -n $NEW_HOSTNAME && $LIVE -eq 0 ]]; then + warn "--hostname wordt genegeerd in image-modus (gebruik TARGET_HOSTNAME van pi-gen)." +elif [[ -n $NEW_HOSTNAME ]]; then + "$LIB/voxip-kiosk-helper" set-hostname "$NEW_HOSTNAME" >/dev/null || die "Ongeldige hostname: $NEW_HOSTNAME" +fi + +if [[ $LIVE -eq 0 ]]; then + say "Klaar (image-modus: services starten bij de eerste boot)" + exit 0 +fi + +say "Services starten" +systemctl restart systemd-journald.service || true +systemctl restart avahi-daemon.service || true +systemctl restart voxip-kiosk-manager.service voxip-kiosk-agent.service +systemctl start voxip-kiosk-healthcheck.timer +systemctl reset-failed voxip-kiosk-x.service voxip-kiosk-browser.service 2>/dev/null || true +systemctl restart voxip-kiosk-x.service +systemctl restart voxip-kiosk-browser.service + +HOST="$(hostname)" +SCHEME=http +grep -q '"admin_https": true' "$ETC/config.json" && SCHEME=https +echo +say "VoxIP Kiosk OS $VERSION is geïnstalleerd." +echo " Beheerinterface: ${SCHEME}://${HOST}.local:${ADMIN_PORT}" +for ip in $(hostname -I 2>/dev/null); do + [[ $ip == *:* ]] || echo " ${SCHEME}://${ip}:${ADMIN_PORT}" +done +if [[ ! -f $ETC/admin.json ]]; then + for _ in $(seq 1 20); do [[ -f $ETC/setup-code ]] && break; sleep 0.5; done + if [[ -f $ETC/setup-code ]]; then + echo " Setupcode: $(cat "$ETC/setup-code") (staat ook op het scherm)" + fi +fi +echo " Status: sudo voxip-kiosk-ctl status" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c70e144 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "voxip-kiosk" +dynamic = ["version"] +description = "VoxIP Kiosk OS — kiosk runtime and VoxIP Kiosk Manager" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +addopts = "-q" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9b2cbd9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Versions match Debian Bookworm (python3-flask 2.2, python3-argon2 21.1) or newer. +flask>=2.2 +argon2-cffi>=21.1 +websocket-client>=1.2 +pytest>=7 diff --git a/src/voxip_kiosk/__init__.py b/src/voxip_kiosk/__init__.py new file mode 100644 index 0000000..50a5d28 --- /dev/null +++ b/src/voxip_kiosk/__init__.py @@ -0,0 +1 @@ +"""VoxIP Kiosk OS — kiosk runtime and VoxIP Kiosk Manager.""" diff --git a/src/voxip_kiosk/agent.py b/src/voxip_kiosk/agent.py new file mode 100644 index 0000000..4673a50 --- /dev/null +++ b/src/voxip_kiosk/agent.py @@ -0,0 +1,275 @@ +"""voxip-kiosk-agent — keeps the kiosk page where it should be. + +Runs as user ``kiosk`` and steers Chromium through the DevTools Protocol on +127.0.0.1: + +* shows the setup page (with setup code) until an admin password exists; +* leaves the local start page / Chromium error page for the kiosk URL as + soon as that URL is reachable, and shows the offline page meanwhile; +* follows URL changes from the web interface without restarting Chromium; +* reloads periodically when ``refresh_interval`` is set — but never while + the URL is unreachable, so a page that works offline keeps working; +* reloads a hung renderer, and closes the browser (systemd restarts it) if + that does not help. +""" + +from __future__ import annotations + +import json +import logging +import signal +import socket +import ssl +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlencode, urlsplit + +from . import logutil, sysinfo +from .cdp import CDP, CDPError +from .config import load_config +from .paths import Paths, load_paths +from .validation import mask_url + +log = logging.getLogger("voxip-kiosk-agent") + +TICK_SECONDS = 5 +PROBE_MAX_AGE_LOADING = 10 +PROBE_MAX_AGE_ONLINE = 60 +HUNG_RELOAD_AFTER = 3 +HUNG_RESTART_AFTER = 6 +BLANK_URLS = {"", "about:blank", "chrome://newtab/", "chrome://new-tab-page/"} + + +def probe(url: str, timeout: float = 8.0) -> tuple[bool, str]: + """Is ``url`` reachable over HTTPS with a valid certificate? → (ok, reason).""" + req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "VoxIP-Kiosk-OS/agent"}) + try: + with urllib.request.urlopen(req, timeout=timeout, context=ssl.create_default_context()): + return True, "" + except urllib.error.HTTPError as exc: + if exc.code >= 500: + return False, f"Serverfout {exc.code}" + return True, "" + except urllib.error.URLError as exc: + return False, _reason(exc.reason) + except (OSError, ValueError) as exc: + return False, _reason(exc) + + +def _reason(exc: Any) -> str: + if isinstance(exc, ssl.SSLCertVerificationError) or "CERTIFICATE_VERIFY_FAILED" in str(exc): + return "Certificaat ongeldig" + if isinstance(exc, ssl.SSLError): + return "TLS-fout" + if isinstance(exc, socket.gaierror): + return "Naam niet gevonden (DNS)" + if isinstance(exc, (socket.timeout, TimeoutError)): + return "Time-out" + if isinstance(exc, ConnectionRefusedError): + return "Verbinding geweigerd" + if isinstance(exc, OSError) and exc.errno in (101, 113): + return "Geen netwerkverbinding" + return "Niet bereikbaar" + + +class Agent: + def __init__(self, paths: Paths, cdp: Any = None, probe_fn: Callable[[str], tuple[bool, str]] = probe, + clock: Callable[[], float] = time.monotonic, wall: Callable[[], float] = time.time, + hostname: Callable[[], str] = lambda: socket.gethostname().split(".")[0], addresses: Callable[[], dict] = sysinfo.ip_addresses): + self.paths = paths + self.cdp = cdp or CDP() + self.probe_fn = probe_fn + self.clock = clock + self.wall = wall + self.hostname = hostname + self.addresses = addresses + self.applied_url: str | None = None + self.last_load: float | None = None + self.hung = 0 + self.offline_since: float | None = None + self.refresh_skipped_logged = False + self._probe: tuple[str, float, bool, str] | None = None + self.state: dict[str, Any] = {"last_error": None} + self._written = "" + self._setup_shown: str | None = None + + # -- helpers ------------------------------------------------------------ + + def local_url(self, page: str, params: dict[str, Any]) -> str: + return f"file://{self.paths.www_dir}/{page}.html?" + urlencode(params) + + def classify(self, url: str) -> str: + prefix = f"file://{self.paths.www_dir}/" + if url.startswith(prefix): + return url[len(prefix):].split(".html", 1)[0] # start / offline / setup + if url.startswith("chrome-error://"): + return "error" + if url in BLANK_URLS: + return "blank" + return "remote" + + def reachable(self, url: str, max_age: float) -> tuple[bool, str]: + now = self.clock() + if self._probe and self._probe[0] == url and now - self._probe[1] < max_age: + return self._probe[2], self._probe[3] + ok, reason = self.probe_fn(url) + self._probe = (url, now, ok, reason) + self.state.update(target_reachable=ok, reason=reason, checked_at=int(self.wall())) + return ok, reason + + def error(self, message: str) -> None: + log.warning(message) + self.state["last_error"] = {"at": int(self.wall()), "message": message} + + def setup_required(self) -> bool: + return self.paths.setup_code_file.exists() and not self.paths.admin_file.exists() + + def setup_url(self, config: dict[str, Any]) -> str: + try: + code = self.paths.setup_code_file.read_text(encoding="utf-8").strip() + except OSError: + code = "" + ips = sorted({a.split("/")[0] for addrs in self.addresses().values() for a in addrs if ":" not in a}) + scheme = "https" if config["admin_https"] else "http" + return self.local_url("setup", {"code": code, "host": self.hostname(), "ips": ",".join(ips), + "port": config["admin_port"], "scheme": scheme, "name": config["device_name"]}) + + def offline_url(self, config: dict[str, Any], reason: str) -> str: + return self.local_url("offline", {"name": config["device_name"], "host": urlsplit(config["url"]).hostname or "", + "reason": reason, "since": int(self.offline_since or self.wall())}) + + def navigate(self, url: str) -> None: + self.cdp.navigate(url) + + # -- main loop ---------------------------------------------------------- + + def tick(self) -> None: + config = load_config(self.paths.config_file) + target = config["url"] + self.state["updated"] = int(self.wall()) + try: + page = self.cdp.main_page() + if page is None: + self.cdp.new_page(self.local_url("start", {"name": config["device_name"], "next": target})) + return + self._tick_page(config, target, page) + self.state["browser"] = "ok" + except CDPError as exc: + self.state.update(browser="unreachable", page=None) + self.hung = 0 + log.debug("DevTools niet bereikbaar: %s", exc) + finally: + self.write_state() + + def _tick_page(self, config: dict[str, Any], target: str, page: dict[str, Any]) -> None: + url = page.get("url", "") + kind = self.classify(url) + self.state.update(page=kind, page_url=mask_url(url) if kind == "remote" else kind) + + if self.setup_required(): + self.state["setup_required"] = True + want = self.setup_url(config) + if kind != "setup" or want != self._setup_shown: + self.navigate(want) + self._setup_shown = want + return + self.state["setup_required"] = False + self._setup_shown = None + + url_changed = self.applied_url is not None and target != self.applied_url + if url_changed: + log.info("Nieuwe kiosk-URL: %s", mask_url(target)) + self.applied_url = target + + if kind != "remote" or url_changed: + ok, reason = self.reachable(target, 0 if url_changed else PROBE_MAX_AGE_LOADING) + if ok: + if self.offline_since is not None: + log.info("Kiosk-URL weer bereikbaar na %.0f s", self.wall() - self.offline_since) + self.navigate(target) + self.last_load = self.clock() + self.offline_since = None + self.state["offline_since"] = None + elif kind != "offline" or url_changed: + if self.offline_since is None: + self.offline_since = self.wall() + self.state["offline_since"] = int(self.offline_since) + self.error(f"Kiosk-URL niet bereikbaar ({reason}); offlinepagina getoond") + self.navigate(self.offline_url(config, reason)) + self.hung = 0 + return + + if self.last_load is None: + self.last_load = self.clock() + + if not self.cdp.responsive(page.get("webSocketDebuggerUrl")): + self.hung += 1 + if self.hung == HUNG_RELOAD_AFTER: + self.error("Pagina reageert niet; herladen") + self.cdp.reload() + self.last_load = self.clock() + elif self.hung >= HUNG_RESTART_AFTER and config["browser_restart"]: + self.error("Pagina reageert nog steeds niet; Chromium wordt herstart") + self.hung = 0 + self.cdp.close_browser() + return + self.hung = 0 + + ok, reason = self.reachable(target, PROBE_MAX_AGE_ONLINE) + interval = config["refresh_interval"] + if interval and self.clock() - self.last_load >= interval: + if ok: + log.info("Automatisch verversen (interval %s s)", interval) + self.cdp.reload() + self.last_load = self.clock() + self.refresh_skipped_logged = False + elif not self.refresh_skipped_logged: + self.error(f"Verversen overgeslagen: kiosk-URL niet bereikbaar ({reason})") + self.refresh_skipped_logged = True + + def write_state(self) -> None: + state = dict(self.state, last_load_wall=int(self.wall() - (self.clock() - self.last_load)) if self.last_load else None) + payload = json.dumps(state, sort_keys=True) + if payload == self._written: + return + try: + tmp = self.paths.agent_state_file.with_suffix(".tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(self.paths.agent_state_file) + self._written = payload + except OSError as exc: + log.debug("Kon agentstatus niet schrijven: %s", exc) + + +def read_state(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def main() -> int: + logutil.setup() + agent = Agent(load_paths()) + running = True + + def stop(*_: Any) -> None: + nonlocal running + running = False + + signal.signal(signal.SIGTERM, stop) + log.info("Kiosk-agent gestart") + while running: + try: + agent.tick() + except Exception: # never die on an unexpected page state; systemd would only restart us + log.exception("Onverwachte fout in de kiosk-agent") + for _ in range(TICK_SECONDS * 2): + if not running: + break + time.sleep(0.5) + return 0 diff --git a/src/voxip_kiosk/browser.py b/src/voxip_kiosk/browser.py new file mode 100644 index 0000000..1790c0b --- /dev/null +++ b/src/voxip_kiosk/browser.py @@ -0,0 +1,243 @@ +"""voxip-kiosk-browser — prepares the display and runs Chromium. + +Started by ``voxip-kiosk-browser.service`` as user ``kiosk`` once the X +server is up. Exit status tells systemd whether to restart: 1 when +``browser_restart`` is enabled (``Restart=on-failure`` restarts it and the +start limit catches crash loops), 0 when it is disabled. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +from . import logutil +from .config import load_config +from .paths import Paths, load_paths +from .validation import parse_resolution + +log = logging.getLogger("voxip-kiosk-browser") + +CDP_PORT = 9222 +CHROMIUM_CANDIDATES = ("/usr/bin/chromium-browser", "/usr/bin/chromium") +ROTATIONS = {0: "normal", 90: "right", 180: "inverted", 270: "left"} + +# Every flag here is deliberate; certificate validation is never weakened. +BASE_FLAGS = [ + "--kiosk", + "--noerrdialogs", + "--disable-infobars", + "--disable-session-crashed-bubble", + "--no-first-run", + "--no-default-browser-check", + "--disable-translate", + "--disable-features=Translate,TranslateUI,MediaRouter,OptimizationHints,HardwareMediaKeyHandling,GlobalMediaControls", + "--disable-component-update", + "--check-for-update-interval=31536000", + "--simulate-outdated-no-au=Tue, 31 Dec 2099 23:59:59 GMT", + "--disable-pinch", + "--overscroll-history-navigation=0", + "--autoplay-policy=no-user-gesture-required", + "--password-store=basic", + "--ozone-platform=x11", + "--window-position=0,0", + "--remote-debugging-address=127.0.0.1", + f"--remote-debugging-port={CDP_PORT}", +] +FORBIDDEN_FLAGS = ("--ignore-certificate-errors", "--ignore-ssl-errors", "--allow-insecure-localhost", "--disable-web-security", "--remote-debugging-address=0.0.0.0") + + +def find_chromium() -> str: + for candidate in CHROMIUM_CANDIDATES: + if os.access(candidate, os.X_OK): + return candidate + raise SystemExit("Chromium is niet geïnstalleerd") + + +def build_args(chromium: str, config: dict[str, Any], profile_dir: Path, cache_dir: Path, start_url: str, + window_size: tuple[int, int] | None = None) -> list[str]: + args = [chromium, *BASE_FLAGS, + f"--user-data-dir={profile_dir}", + f"--disk-cache-dir={cache_dir}", + "--disk-cache-size=33554432"] + if window_size: + args.append(f"--window-size={window_size[0]},{window_size[1]}") + zoom = float(config.get("zoom", 1.0)) + if abs(zoom - 1.0) > 0.001: + args.append(f"--force-device-scale-factor={zoom:g}") + args.append(start_url) + assert not any(a.startswith(FORBIDDEN_FLAGS) for a in args) + return args + + +def start_page_url(paths: Paths, config: dict[str, Any]) -> str: + query = urlencode({"name": config["device_name"], "next": config["url"]}) + return f"file://{paths.www_dir}/start.html?{query}" + + +# -- display ------------------------------------------------------------- + +def parse_xrandr(output: str) -> list[dict[str, Any]]: + """Connected outputs with their modes from ``xrandr --query``.""" + outputs: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + for line in output.splitlines(): + head = re.match(r"^(\S+) (connected|disconnected)(?: primary)?(?: (\d+)x(\d+)\+\d+\+\d+)?", line) + if head: + current = None + if head.group(2) == "connected": + current = {"name": head.group(1), "modes": [], "current": None, "preferred": None} + if head.group(3): + current["size"] = (int(head.group(3)), int(head.group(4))) + outputs.append(current) + continue + mode = re.match(r"^\s+(\d+x\d+i?)\s+(.*)$", line) + if mode and current is not None: + name = mode.group(1) + rates = re.findall(r"(\d+\.\d+)([ *+]*)", mode.group(2)) + if name not in current["modes"]: + current["modes"].append(name) + for rate, marks in rates: + if "*" in marks: + current["current"] = f"{name}@{rate}" + if "+" in marks and current["preferred"] is None: + current["preferred"] = name + return outputs + + +def xrandr_args(output: dict[str, Any], config: dict[str, Any]) -> list[str]: + args = ["xrandr", "--output", output["name"]] + res = parse_resolution(config.get("resolution", "auto")) + if res and res[0] in output["modes"]: + args += ["--mode", res[0]] + if res[1]: + args += ["--rate", res[1]] + else: + if res: + log.warning("Resolutie %s niet ondersteund door %s; automatisch gekozen", config["resolution"], output["name"]) + args.append("--auto") + args += ["--rotate", ROTATIONS.get(int(config.get("rotation", 0)), "normal")] + return args + + +def xset_commands(config: dict[str, Any]) -> list[list[str]]: + if config.get("screen_always_on", True): + return [["xset", "s", "off"], ["xset", "s", "noblank"], ["xset", "-dpms"]] + seconds = int(config.get("screen_off_minutes", 10)) * 60 + return [["xset", "s", "off"], ["xset", "+dpms"], ["xset", "dpms", "0", "0", str(seconds)]] + + +def _run(args: list[str]) -> subprocess.CompletedProcess | None: + try: + return subprocess.run(args, capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError) as exc: + log.warning("%s mislukt: %s", args[0], exc) + return None + + +def wait_for_x(timeout: float = 60) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + proc = _run(["xset", "q"]) + if proc is not None and proc.returncode == 0: + return True + time.sleep(1) + return False + + +def prepare_display(paths: Paths, config: dict[str, Any]) -> tuple[int, int] | None: + outputs: list[dict[str, Any]] = [] + # A monitor that powers up after the Pi may need a moment to report itself. + for _ in range(15): + proc = _run(["xrandr", "--query"]) + outputs = parse_xrandr(proc.stdout) if proc and proc.returncode == 0 else [] + if outputs: + break + time.sleep(2) + size = None + if outputs: + output = outputs[0] + proc = _run(xrandr_args(output, config)) + if proc is not None and proc.returncode != 0: + log.warning("xrandr: %s", proc.stderr.strip()) + refreshed = _run(["xrandr", "--query"]) + if refreshed and refreshed.returncode == 0: + outputs = parse_xrandr(refreshed.stdout) or outputs + output = outputs[0] + size = output.get("size") + else: + log.warning("Geen aangesloten scherm gevonden") + for cmd in xset_commands(config): + _run(cmd) + info = {"outputs": outputs, "rotation": config.get("rotation", 0), "updated": int(time.time())} + try: + paths.display_info_file.write_text(json.dumps(info), encoding="utf-8") + except OSError as exc: + log.warning("Kon schermgegevens niet opslaan: %s", exc) + return size + + +# -- profile ------------------------------------------------------------- + +def clean_profile(profile_dir: Path) -> None: + """Remove stale locks and the 'did not shut down correctly' flag left by a crash or power cut.""" + for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"): + try: + (profile_dir / name).unlink() + except FileNotFoundError: + pass + except OSError as exc: + log.warning("Kon %s niet verwijderen: %s", name, exc) + prefs = profile_dir / "Default" / "Preferences" + try: + data = json.loads(prefs.read_text(encoding="utf-8")) + except (OSError, ValueError): + return + profile = data.setdefault("profile", {}) + profile["exit_type"] = "Normal" + profile["exited_cleanly"] = True + tmp = prefs.with_suffix(".voxip-tmp") + tmp.write_text(json.dumps(data), encoding="utf-8") + os.replace(tmp, prefs) + + +def profile_dirs(config: dict[str, Any]) -> tuple[Path, Path]: + runtime = Path(os.environ.get("RUNTIME_DIRECTORY", "/run/voxip-kiosk-browser")) + state = Path(os.environ.get("STATE_DIRECTORY", "/var/lib/voxip-kiosk-browser")) + profile = state / "profile" if config.get("browser_profile") == "persistent" else runtime / "profile" + if config.get("browser_profile") != "persistent" and (state / "profile").exists(): + shutil.rmtree(state / "profile", ignore_errors=True) + cache = runtime / "cache" + profile.mkdir(parents=True, exist_ok=True, mode=0o700) + cache.mkdir(parents=True, exist_ok=True, mode=0o700) + return profile, cache + + +def main() -> int: + logutil.setup() + paths = load_paths() + config = load_config(paths.config_file) + if not wait_for_x(): + log.error("X-server niet bereikbaar") + return 1 + size = prepare_display(paths, config) + profile, cache = profile_dirs(config) + clean_profile(profile) + args = build_args(find_chromium(), config, profile, cache, start_page_url(paths, config), size) + log.info("Chromium starten (profiel: %s, zoom: %s, rotatie: %s°)", config["browser_profile"], config["zoom"], config["rotation"]) + started = time.monotonic() + code = subprocess.call(args) + lifetime = time.monotonic() - started + if config.get("browser_restart", True): + log.warning("Chromium gestopt (exitcode %s na %.0f s); systemd start opnieuw", code, lifetime) + return 1 + log.warning("Chromium gestopt (exitcode %s); automatisch herstarten staat uit", code) + return 0 diff --git a/src/voxip_kiosk/cdp.py b/src/voxip_kiosk/cdp.py new file mode 100644 index 0000000..dbdca05 --- /dev/null +++ b/src/voxip_kiosk/cdp.py @@ -0,0 +1,126 @@ +"""Minimal Chrome DevTools Protocol client for the local kiosk browser. + +Chromium listens on 127.0.0.1 only (``--remote-debugging-address``); this +client refuses any other host. Each call opens a short-lived WebSocket to +the page target, which keeps the client stateless and robust against +browser restarts. +""" + +from __future__ import annotations + +import base64 +import itertools +import json +import urllib.request +from typing import Any + +DEFAULT_PORT = 9222 +_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"} + + +class CDPError(Exception): + pass + + +class CDP: + def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT, timeout: float = 5.0): + if host not in _LOCAL_HOSTS: + raise ValueError("DevTools is alleen lokaal bereikbaar") + self.host = host + self.port = port + self.timeout = timeout + self._ids = itertools.count(1) + + # -- HTTP endpoints ------------------------------------------------- + + def _http_json(self, path: str, method: str = "GET") -> Any: + req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", method=method) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except (OSError, ValueError) as exc: + raise CDPError(f"DevTools niet bereikbaar: {exc}") from exc + + def version(self) -> dict[str, Any]: + return self._http_json("/json/version") + + def pages(self) -> list[dict[str, Any]]: + return [t for t in self._http_json("/json/list") if t.get("type") == "page"] + + def main_page(self) -> dict[str, Any] | None: + pages = self.pages() + return pages[0] if pages else None + + def new_page(self, url: str) -> dict[str, Any]: + from urllib.parse import quote + + return self._http_json("/json/new?" + quote(url, safe=""), method="PUT") + + # -- WebSocket commands --------------------------------------------- + + def call(self, ws_url: str, method: str, params: dict[str, Any] | None = None, timeout: float | None = None) -> dict[str, Any]: + try: + import websocket # websocket-client (python3-websocket) + except ImportError as exc: # pragma: no cover - dependency missing + raise CDPError("python3-websocket is niet geïnstalleerd") from exc + + msg_id = next(self._ids) + try: + # Chromium rejects DevTools WebSockets that carry a foreign Origin; + # we send none at all. + ws = websocket.create_connection(ws_url, timeout=timeout or self.timeout, suppress_origin=True) + except Exception as exc: + raise CDPError(f"Verbinding met DevTools mislukt: {exc}") from exc + try: + ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}})) + while True: + reply = json.loads(ws.recv()) + if reply.get("id") == msg_id: + break + except Exception as exc: + raise CDPError(f"DevTools-opdracht {method} mislukt: {exc}") from exc + finally: + try: + ws.close() + except Exception: + pass + if "error" in reply: + raise CDPError(f"{method}: {reply['error'].get('message', reply['error'])}") + return reply.get("result", {}) + + def _page_ws(self) -> str: + page = self.main_page() + if not page or not page.get("webSocketDebuggerUrl"): + raise CDPError("Geen browserpagina gevonden") + return page["webSocketDebuggerUrl"] + + def current_url(self) -> str | None: + page = self.main_page() + return page.get("url") if page else None + + def navigate(self, url: str) -> None: + self.call(self._page_ws(), "Page.navigate", {"url": url}) + + def reload(self, ignore_cache: bool = False) -> None: + self.call(self._page_ws(), "Page.reload", {"ignoreCache": ignore_cache}) + + def responsive(self, ws_url: str | None = None, timeout: float = 5.0) -> bool: + """True when the page's renderer still executes JavaScript.""" + try: + result = self.call(ws_url or self._page_ws(), "Runtime.evaluate", {"expression": "1+1", "returnByValue": True}, timeout=timeout) + except CDPError: + return False + return result.get("result", {}).get("value") == 2 + + def screenshot(self, quality: int = 70) -> bytes: + result = self.call(self._page_ws(), "Page.captureScreenshot", {"format": "jpeg", "quality": quality}, timeout=15) + try: + return base64.b64decode(result["data"]) + except (KeyError, ValueError) as exc: + raise CDPError("Ongeldige screenshot ontvangen") from exc + + def close_browser(self) -> None: + ws_url = self.version().get("webSocketDebuggerUrl") + if not ws_url: + raise CDPError("Geen browser-endpoint gevonden") + self.call(ws_url, "Browser.close") diff --git a/src/voxip_kiosk/config.py b/src/voxip_kiosk/config.py new file mode 100644 index 0000000..40ab28a --- /dev/null +++ b/src/voxip_kiosk/config.py @@ -0,0 +1,249 @@ +"""Kiosk configuration: defaults, validation and atomic persistence. + +``load_config`` never raises: a missing, corrupt or partially invalid file +falls back to the ``.bak`` copy and then to defaults per key, so a bad edit +can never keep the kiosk from booting. ``save_config`` validates the whole +result before anything touches the disk. +""" + +from __future__ import annotations + +import copy +import fcntl +import json +import logging +import os +import tempfile +import threading +from pathlib import Path +from typing import Any, Callable + +from . import validation as v + +log = logging.getLogger(__name__) + +DEFAULTS: dict[str, Any] = { + "device_name": "VoxIP Kiosk", + "url": "https://schermen-ijsselflat.voxip.nl", + "refresh_interval": 0, + "screen_always_on": True, + "screen_off_minutes": 10, + "rotation": 0, + "resolution": "auto", + "zoom": 1.0, + "browser_restart": True, + "browser_profile": "persistent", + "admin_port": 8080, + "admin_https": False, + "admin_allow_public": False, +} + + +def _bool(value: Any) -> bool: + if isinstance(value, bool): + return value + raise v.ValidationError("Verwacht ja/nee.") + + +def _int_range(lo: int, hi: int, message: str, allow_zero: bool = False) -> Callable[[Any], int]: + def check(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise v.ValidationError(message) + try: + number = int(value) + except (TypeError, ValueError): + raise v.ValidationError(message) from None + if allow_zero and number == 0: + return 0 + if not lo <= number <= hi: + raise v.ValidationError(message) + return number + + return check + + +def _rotation(value: Any) -> int: + try: + number = int(value) + except (TypeError, ValueError): + number = -1 + if isinstance(value, bool) or number not in (0, 90, 180, 270): + raise v.ValidationError("Rotatie moet 0, 90, 180 of 270 graden zijn.") + return number + + +def _zoom(value: Any) -> float: + try: + number = round(float(value), 2) + except (TypeError, ValueError): + number = -1.0 + if isinstance(value, bool) or not 0.25 <= number <= 4.0: + raise v.ValidationError("Zoomfactor moet tussen 0,25 en 4 liggen.") + return number + + +def _choice(*options: str) -> Callable[[Any], str]: + def check(value: Any) -> str: + if value not in options: + raise v.ValidationError("Kies een van: " + ", ".join(options) + ".") + return value + + return check + + +def _str(fn: Callable[[str], Any]) -> Callable[[Any], Any]: + def check(value: Any) -> Any: + if not isinstance(value, str): + raise v.ValidationError("Verwacht tekst.") + return fn(value) + + return check + + +VALIDATORS: dict[str, Callable[[Any], Any]] = { + "device_name": _str(v.device_name), + "url": _str(v.kiosk_url), + "refresh_interval": _int_range(30, 86400, "Verversinterval: 0 (uit) of 30 tot 86400 seconden.", allow_zero=True), + "screen_always_on": _bool, + "screen_off_minutes": _int_range(1, 240, "Scherm uit na: 1 tot 240 minuten."), + "rotation": _rotation, + "resolution": _str(v.resolution), + "zoom": _zoom, + "browser_restart": _bool, + "browser_profile": _choice("persistent", "ephemeral"), + "admin_port": _int_range(1024, 65535, "Poort moet tussen 1024 en 65535 liggen."), + "admin_https": _bool, + "admin_allow_public": _bool, +} + + +class ConfigError(Exception): + def __init__(self, errors: dict[str, str]): + super().__init__("; ".join(f"{k}: {m}" for k, m in errors.items())) + self.errors = errors + + +def validate(updates: dict[str, Any]) -> dict[str, Any]: + """Validate known keys in ``updates``; raise ConfigError listing every problem.""" + clean: dict[str, Any] = {} + errors: dict[str, str] = {} + for key, value in updates.items(): + check = VALIDATORS.get(key) + if check is None: + errors[key] = "Onbekende instelling." + continue + try: + clean[key] = check(value) + except v.ValidationError as exc: + errors[key] = str(exc) + if errors: + raise ConfigError(errors) + return clean + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + with path.open(encoding="utf-8") as fh: + data = json.load(fh) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + log.warning("Kan %s niet lezen: %s", path, exc) + return None + if not isinstance(data, dict): + log.warning("%s bevat geen JSON-object", path) + return None + return data + + +def _read_raw(path: Path) -> dict[str, Any]: + data = _read_json(path) + if data is None and path.exists(): + data = _read_json(path.with_suffix(path.suffix + ".bak")) + if data is not None: + log.warning("Configuratie hersteld uit %s.bak", path.name) + return data or {} + + +def load_config(path: Path) -> dict[str, Any]: + """Effective configuration: defaults overlaid with every valid stored value.""" + raw = _read_raw(path) + config = copy.deepcopy(DEFAULTS) + for key, value in raw.items(): + check = VALIDATORS.get(key) + if check is None: + continue + try: + config[key] = check(value) + except v.ValidationError as exc: + log.warning("Ongeldige waarde voor %s in %s (%s); standaard gebruikt", key, path, exc) + return config + + +def atomic_write(path: Path, content: bytes, mode: int = 0o640, owner: tuple[int, int] | None = None) -> None: + """Write ``content`` to ``path`` so readers see either the old or the new file, never a partial one.""" + path = Path(path) + if owner is None and path.exists(): + st = path.stat() + owner = (st.st_uid, st.st_gid) + fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.chmod(tmp, mode) + if owner is not None and os.geteuid() == 0: + os.chown(tmp, *owner) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + dir_fd = os.open(str(path.parent), os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + + +def atomic_write_json(path: Path, data: Any, mode: int = 0o640, owner: tuple[int, int] | None = None) -> None: + content = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + atomic_write(path, content, mode=mode, owner=owner) + + +_thread_lock = threading.Lock() + + +def save_config(path: Path, updates: dict[str, Any]) -> dict[str, Any]: + """Validate ``updates``, merge them into the stored file and write it atomically. + + Unknown keys already in the file are preserved; the previous version is + kept as ``config.json.bak``. Returns the new effective configuration. + """ + clean = validate(updates) + path = Path(path) + lock_path = path.with_name(".config.lock") + with _thread_lock: + # Read-only is enough for flock and works for every writer (the web + # interface as voxip, the helper as root), whoever created the file. + lock_fd = os.open(str(lock_path), os.O_RDONLY | os.O_CREAT, 0o640) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + return _save_locked(path, clean) + finally: + os.close(lock_fd) + + +def _save_locked(path: Path, clean: dict[str, Any]) -> dict[str, Any]: + raw = _read_raw(path) + if path.exists(): + try: + atomic_write(path.with_suffix(path.suffix + ".bak"), path.read_bytes()) + except OSError as exc: + log.warning("Kon geen back-up van de configuratie maken: %s", exc) + raw.update(clean) + atomic_write_json(path, raw) + return load_config(path) diff --git a/src/voxip_kiosk/ctl.py b/src/voxip_kiosk/ctl.py new file mode 100644 index 0000000..b47724a --- /dev/null +++ b/src/voxip_kiosk/ctl.py @@ -0,0 +1,164 @@ +"""voxip-kiosk-ctl — command-line management over SSH (run with sudo).""" + +from __future__ import annotations + +import argparse +import grp +import json +import os +import pwd +import subprocess +import sys + +from . import services, setupcode +from .agent import read_state +from .cdp import CDP, CDPError +from .config import ConfigError, load_config, save_config +from .healthcheck import HealthCheck +from .helper import Helper, HelperError +from .paths import default_paths, read_version +from .validation import mask_url + + +def _require_root() -> None: + if os.geteuid() != 0: + sys.exit("Voer dit commando uit met sudo.") + + +def cmd_status(args) -> int: + paths = default_paths() + config = load_config(paths.config_file) + agent = read_state(paths.agent_state_file) + print(f"VoxIP Kiosk OS {read_version(paths)}") + print(f"Apparaat: {config['device_name']}") + print(f"URL: {config['url'] if args.show_secrets else mask_url(config['url'])}") + print(f"Beheer: {'https' if config['admin_https'] else 'http'}://{os.uname().nodename}.local:{config['admin_port']}") + print(f"Wachtwoord: {'ingesteld' if paths.admin_file.exists() else 'NOG NIET INGESTELD (setupcode: sudo voxip-kiosk-ctl setup-code)'}") + for key, info in services.summary().items(): + print(f"{info['unit']:<34} {info['label']} (herstarts: {info['restarts']})") + if agent: + print(f"Pagina: {agent.get('page')} bereikbaar: {agent.get('target_reachable')} {agent.get('reason') or ''}") + return 0 + + +def cmd_setup_code(_args) -> int: + paths = default_paths() + if paths.admin_file.exists(): + print("Er is al een beheerderswachtwoord ingesteld. Gebruik 'reset-password' om opnieuw te beginnen.") + return 1 + code = setupcode.read(paths.setup_code_file) + if not code: + print("Nog geen setupcode; start de beheerinterface: sudo systemctl restart voxip-kiosk-manager") + return 1 + print(code) + return 0 + + +def cmd_reset_password(args) -> int: + _require_root() + paths = default_paths() + if not args.yes: + answer = input("Beheerderswachtwoord verwijderen en een nieuwe setupcode maken? [j/N] ") + if answer.strip().lower() not in ("j", "ja", "y", "yes"): + return 1 + paths.admin_file.unlink(missing_ok=True) + code = setupcode.generate() + setupcode.write(paths.setup_code_file, code) + try: + os.chown(paths.setup_code_file, pwd.getpwnam("voxip").pw_uid, grp.getgrnam("kiosk").gr_gid) + except KeyError: + pass + subprocess.run(["systemctl", "restart", "voxip-kiosk-manager.service"], check=False) + print(f"Wachtwoord verwijderd. Nieuwe setupcode: {code}") + print("Open de beheerinterface en stel een nieuw wachtwoord in; de code staat ook op het kioskscherm.") + return 0 + + +def cmd_set_url(args) -> int: + _require_root() + try: + config = save_config(default_paths().config_file, {"url": args.url}) + except ConfigError as exc: + print("; ".join(exc.errors.values()), file=sys.stderr) + return 1 + print(f"Kiosk-URL ingesteld: {mask_url(config['url'])}") + return 0 + + +def cmd_reload(_args) -> int: + try: + CDP().reload() + except CDPError as exc: + print(exc, file=sys.stderr) + return 1 + print("Pagina wordt herladen.") + return 0 + + +def cmd_restart_browser(_args) -> int: + _require_root() + try: + print(Helper().restart_browser()["message"]) + except HelperError as exc: + print(exc, file=sys.stderr) + return 1 + return 0 + + +def cmd_health(_args) -> int: + report = HealthCheck(default_paths()).run(act=False) + print(json.dumps(report, indent=2, ensure_ascii=False)) + return 0 + + +def cmd_maintenance(args) -> int: + _require_root() + flag = default_paths().health_run_dir / "maintenance" + if args.state == "on": + flag.parent.mkdir(parents=True, exist_ok=True) + flag.touch() + print("Onderhoudsmodus aan: de healthcheck herstart niets tot 'maintenance off' of een reboot.") + else: + flag.unlink(missing_ok=True) + print("Onderhoudsmodus uit.") + return 0 + + +def cmd_config(args) -> int: + config = load_config(default_paths().config_file) + if not args.show_secrets: + config["url"] = mask_url(config["url"]) + print(json.dumps(config, indent=2, ensure_ascii=False)) + return 0 + + +def cmd_version(_args) -> int: + print(read_version(default_paths())) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="voxip-kiosk-ctl", description="Beheer van VoxIP Kiosk OS vanaf de commandoregel.") + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("status", help="toon de status") + p.add_argument("--show-secrets", action="store_true", help="toon de volledige URL inclusief token") + p.set_defaults(func=cmd_status) + sub.add_parser("setup-code", help="toon de setupcode voor de eerste configuratie").set_defaults(func=cmd_setup_code) + p = sub.add_parser("reset-password", help="verwijder het beheerderswachtwoord (nieuwe setupcode)") + p.add_argument("-y", "--yes", action="store_true") + p.set_defaults(func=cmd_reset_password) + p = sub.add_parser("set-url", help="stel de kiosk-URL in") + p.add_argument("url") + p.set_defaults(func=cmd_set_url) + sub.add_parser("reload", help="herlaad de pagina").set_defaults(func=cmd_reload) + sub.add_parser("restart-browser", help="herstart Chromium").set_defaults(func=cmd_restart_browser) + sub.add_parser("health", help="healthcheck-rapport (zonder herstelacties)").set_defaults(func=cmd_health) + p = sub.add_parser("maintenance", help="onderhoudsmodus aan/uit") + p.add_argument("state", choices=("on", "off")) + p.set_defaults(func=cmd_maintenance) + p = sub.add_parser("config", help="toon de configuratie") + p.add_argument("--show-secrets", action="store_true") + p.set_defaults(func=cmd_config) + sub.add_parser("version", help="toon de versie").set_defaults(func=cmd_version) + args = parser.parse_args(argv) + return args.func(args) diff --git a/src/voxip_kiosk/healthcheck.py b/src/voxip_kiosk/healthcheck.py new file mode 100644 index 0000000..114683d --- /dev/null +++ b/src/voxip_kiosk/healthcheck.py @@ -0,0 +1,161 @@ +"""voxip-kiosk-healthcheck — runs every minute (timer) as root. + +Keeps every kiosk service alive without hiding problems: each recovery is +logged, and a crash loop that hit systemd's start limit is retried only +after a cool-down, so the journal shows the loop instead of an endless +stream of restarts. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import subprocess +import time +from pathlib import Path +from typing import Any + +from . import logutil, services, sysinfo +from .cdp import CDP, CDPError +from .config import load_config +from .paths import Paths, load_paths + +log = logging.getLogger("voxip-kiosk-healthcheck") + +ALWAYS_ON = (services.AGENT_UNIT, services.MANAGER_UNIT) +DISPLAY_UNITS = (services.X_UNIT, services.BROWSER_UNIT) +DISPLAY_RETRY_AFTER = 300 +CDP_GRACE = 90 +CDP_FAILURES_BEFORE_RESTART = 3 +WARN_EVERY = 3600 + + +def _systemctl(*args: str) -> None: + try: + subprocess.run(["systemctl", *args], capture_output=True, timeout=30) + except (OSError, subprocess.SubprocessError) as exc: + log.error("systemctl %s mislukt: %s", " ".join(args), exc) + + +class HealthCheck: + def __init__(self, paths: Paths, unit_status=services.unit_status, systemctl=_systemctl, cdp: Any = None, + throttled=sysinfo.throttled, disk=sysinfo.disk, now=time.time): + self.paths = paths + self.unit_status = unit_status + self.systemctl = systemctl + self.cdp = cdp or CDP(timeout=4) + self.throttled = throttled + self.disk = disk + self.now = now + + def load_state(self) -> dict[str, Any]: + try: + data = json.loads(self.paths.health_state_file.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + def save_state(self, state: dict[str, Any]) -> None: + try: + self.paths.health_state_file.parent.mkdir(parents=True, exist_ok=True) + self.paths.health_state_file.write_text(json.dumps(state), encoding="utf-8") + except OSError as exc: + log.warning("Kon healthcheck-status niet opslaan: %s", exc) + + def maintenance(self) -> bool: + return (self.paths.health_run_dir / "maintenance").exists() + + def run(self, act: bool = True) -> dict[str, Any]: + now = self.now() + state = self.load_state() + failed_since: dict[str, float] = state.setdefault("failed_since", {}) + warned: dict[str, float] = state.setdefault("warned", {}) + config = load_config(self.paths.config_file) + report: dict[str, Any] = {"units": {}, "actions": [], "maintenance": self.maintenance()} + act = act and not report["maintenance"] + + for unit in (*DISPLAY_UNITS, *ALWAYS_ON): + props = self.unit_status(unit) + active = props.get("ActiveState", "unknown") + report["units"][unit] = active + is_display = unit in DISPLAY_UNITS + if active == "failed": + if unit not in failed_since: + failed_since[unit] = now + log.error("%s is in fouttoestand (resultaat: %s, herstarts: %s)", unit, props.get("Result"), props.get("NRestarts")) + if is_display and not config["browser_restart"]: + continue + wait = DISPLAY_RETRY_AFTER if is_display else 0 + if act and now - failed_since[unit] >= wait: + log.warning("%s opnieuw starten na fouttoestand", unit) + self.systemctl("reset-failed", unit) + self.systemctl("start", "--no-block", unit) + report["actions"].append(f"start {unit}") + del failed_since[unit] + elif active == "inactive": + failed_since.pop(unit, None) + should_run = not is_display or config["browser_restart"] + if act and should_run: + log.warning("%s draaide niet; wordt gestart", unit) + self.systemctl("start", "--no-block", unit) + report["actions"].append(f"start {unit}") + else: + failed_since.pop(unit, None) + + self._check_devtools(state, report, now, act) + self._check_hardware(warned, report, now) + self.save_state(state) + return report + + def _check_devtools(self, state: dict[str, Any], report: dict[str, Any], now: float, act: bool) -> None: + if report["units"].get(services.BROWSER_UNIT) != "active": + state.pop("browser_active_since", None) + state["cdp_failures"] = 0 + return + since = state.setdefault("browser_active_since", now) + if now - since < CDP_GRACE: + return + try: + self.cdp.version() + state["cdp_failures"] = 0 + report["devtools"] = "ok" + except CDPError: + state["cdp_failures"] = state.get("cdp_failures", 0) + 1 + report["devtools"] = "geen antwoord" + log.warning("Chromium reageert niet op DevTools (%s keer achter elkaar)", state["cdp_failures"]) + if act and state["cdp_failures"] >= CDP_FAILURES_BEFORE_RESTART: + log.error("Chromium wordt herstart: DevTools reageert niet") + self.systemctl("restart", "--no-block", services.BROWSER_UNIT) + report["actions"].append(f"restart {services.BROWSER_UNIT}") + state["cdp_failures"] = 0 + state.pop("browser_active_since", None) + + def _check_hardware(self, warned: dict[str, float], report: dict[str, Any], now: float) -> None: + throttle = self.throttled() + if throttle and throttle["now"]: + report["throttled"] = throttle["flags"] + if now - warned.get("throttled", 0) >= WARN_EVERY: + log.warning("Voeding/temperatuur: %s — controleer de voedingsadapter", ", ".join(throttle["flags"])) + warned["throttled"] = now + usage = self.disk("/") + if usage and usage["percent"] >= 90: + report["disk_percent"] = usage["percent"] + if now - warned.get("disk", 0) >= WARN_EVERY: + log.warning("SD-kaart is voor %s%% vol", usage["percent"]) + warned["disk"] = now + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="voxip-kiosk-healthcheck") + parser.add_argument("--check-only", action="store_true", help="alleen rapporteren, niets herstellen") + parser.add_argument("--json", action="store_true", help="rapport als JSON") + args = parser.parse_args(argv) + logutil.setup() + report = HealthCheck(load_paths()).run(act=not args.check_only) + if args.json: + print(json.dumps(report, indent=2, ensure_ascii=False)) + else: + units = ", ".join(f"{Path(u).stem}={s}" for u, s in report["units"].items()) + log.info("Healthcheck: %s%s", units, f"; acties: {', '.join(report['actions'])}" if report["actions"] else "") + return 0 diff --git a/src/voxip_kiosk/helper.py b/src/voxip_kiosk/helper.py new file mode 100644 index 0000000..772e757 --- /dev/null +++ b/src/voxip_kiosk/helper.py @@ -0,0 +1,446 @@ +"""voxip-kiosk-helper — the only privileged entry point of VoxIP Kiosk OS. + +Runs as root, either through ``sudo -n`` from the web interface (user +``voxip``) or from systemd units. It accepts a fixed set of verbs, validates +every argument itself, never invokes a shell and prints a single JSON +object on stdout. + +Usage: voxip-kiosk-helper [args] (network-apply reads JSON on stdin) +""" + +from __future__ import annotations + +import grp +import json +import os +import pwd +import shutil +import subprocess +import sys +import syslog +import time +from pathlib import Path +from typing import Any, Callable + +from . import config as cfg +from . import netconfig +from . import validation as v +from .paths import Paths, default_paths + +HELPER_PATH = "/usr/lib/voxip-kiosk/voxip-kiosk-helper" +ROLLBACK_UNIT = "voxip-network-rollback" +ACTIVATE_UNIT = "voxip-network-activate" +BOOT_DIRS = (Path("/boot/firmware"), Path("/boot")) +RESET_TRIGGER = "voxip-network-reset" +IMPORT_CONFIG = "voxip-config.json" +IMPORT_KEYS = "voxip-authorized_keys" +SSH_KEY_TYPES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-") + +# Verbs the web interface may call through sudo. Everything else is only +# reachable from systemd units or a root shell. +PUBLIC_VERBS = { + "reboot", "poweroff", "restart-browser", "restart-manager", "set-hostname", "set-wifi-country", + "wifi-scan", "network-apply", "network-confirm", "network-rollback", "network-pending", +} + + +class HelperError(Exception): + pass + + +def log(priority: int, message: str) -> None: + syslog.syslog(priority, message) + print(message, file=sys.stderr) + + +class Runner: + """subprocess wrapper; tests replace it with a recorder.""" + + def __call__(self, args: list[str], timeout: float = 30, check: bool = True, input: str | None = None) -> subprocess.CompletedProcess: + try: + proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=input) + except (OSError, subprocess.SubprocessError) as exc: + raise HelperError(f"{args[0]}: {exc}") from exc + if check and proc.returncode != 0: + raise HelperError(f"{' '.join(args[:3])} mislukt: {(proc.stderr or proc.stdout).strip()[:300]}") + return proc + + +class Helper: + def __init__(self, paths: Paths | None = None, run: Callable[..., Any] | None = None, + etc: Path = Path("/etc"), boot_dirs: tuple[Path, ...] = BOOT_DIRS, helper_path: str = HELPER_PATH): + self.paths = paths or default_paths() + self.run = run or Runner() + self.etc = etc + self.boot_dirs = boot_dirs + self.helper_path = helper_path + + # -- power & browser ------------------------------------------------- + + def reboot(self) -> dict[str, Any]: + self.run(["systemd-run", "--on-active=3", "--unit=voxip-power-reboot", "/bin/systemctl", "reboot"]) + log(syslog.LOG_NOTICE, "Herstart aangevraagd via beheerinterface") + return {"message": "De Raspberry Pi wordt over enkele seconden herstart."} + + def poweroff(self) -> dict[str, Any]: + self.run(["systemd-run", "--on-active=3", "--unit=voxip-power-off", "/bin/systemctl", "poweroff"]) + log(syslog.LOG_NOTICE, "Uitschakelen aangevraagd via beheerinterface") + return {"message": "De Raspberry Pi wordt over enkele seconden uitgeschakeld."} + + def restart_browser(self) -> dict[str, Any]: + self.run(["systemctl", "reset-failed", "voxip-kiosk-browser.service", "voxip-kiosk-x.service"], check=False) + self.run(["systemctl", "restart", "--no-block", "voxip-kiosk-browser.service"]) + log(syslog.LOG_NOTICE, "Chromium wordt herstart") + return {"message": "Chromium wordt herstart."} + + def restart_manager(self) -> dict[str, Any]: + # Delayed so the web interface can still answer the request that asked for it. + self.run(["systemd-run", "--on-active=2", "--unit=voxip-manager-restart", "/bin/systemctl", "restart", "voxip-kiosk-manager.service"]) + log(syslog.LOG_NOTICE, "Beheerinterface wordt herstart") + return {"message": "De beheerinterface wordt herstart."} + + # -- hostname ------------------------------------------------------- + + def set_hostname(self, name: str) -> dict[str, Any]: + name = v.hostname(name) + cfg.atomic_write(self.etc / "hostname", (name + "\n").encode(), mode=0o644, owner=(0, 0)) + hosts_file = self.etc / "hosts" + try: + lines = hosts_file.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + lines = ["127.0.0.1\tlocalhost"] + lines = [line for line in lines if not line.startswith("127.0.1.1")] + lines.append(f"127.0.1.1\t{name}") + cfg.atomic_write(hosts_file, ("\n".join(lines) + "\n").encode(), mode=0o644, owner=(0, 0)) + self.run(["hostname", name], check=False) + self.run(["systemctl", "try-restart", "--no-block", "avahi-daemon.service"], check=False) + log(syslog.LOG_NOTICE, f"Hostname gewijzigd naar {name}") + return {"message": f"Hostname is nu {name}. Beheer bereikbaar via http://{name}.local.", "hostname": name} + + # -- wifi ----------------------------------------------------------- + + def set_wifi_country(self, code: str) -> dict[str, Any]: + code = v.country_code(code) + if shutil.which("raspi-config"): + self.run(["raspi-config", "nonint", "do_wifi_country", code]) + else: + self.run(["iw", "reg", "set", code]) + self.run(["rfkill", "unblock", "wifi"], check=False) + log(syslog.LOG_NOTICE, f"Wifi-land ingesteld op {code}") + return {"message": f"Wifi-land ingesteld op {code}.", "country": code} + + def wifi_scan(self) -> dict[str, Any]: + self.run(["rfkill", "unblock", "wifi"], check=False) + proc = self.run(["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "ifname", "wlan0", "--rescan", "yes"], timeout=40) + return {"networks": netconfig.parse_wifi_scan(proc.stdout)} + + # -- network change with rollback ------------------------------------- + + def _pending(self) -> dict[str, Any] | None: + try: + data = json.loads(self.paths.network_pending_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + def _keyfile_path(self, interface: str) -> Path: + return self.paths.nm_connections_dir / f"{netconfig.profile_name(interface)}.nmconnection" + + def _existing_profile(self, interface: str): + try: + return netconfig.read_keyfile(self._keyfile_path(interface).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + def _active_uuids(self) -> list[str]: + proc = self.run(["nmcli", "-t", "-f", "UUID", "connection", "show", "--active"], check=False) + return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] + + def _snapshot(self) -> None: + backup = self.paths.network_backup_dir + if backup.exists(): + shutil.rmtree(backup) + backup.mkdir(parents=True, mode=0o700) + for item in self.paths.nm_connections_dir.iterdir(): + if item.is_file(): + shutil.copy2(item, backup / item.name) + + def _schedule(self, unit: str, delay: int, *args: str) -> None: + self.run(["systemctl", "stop", f"{unit}.timer", f"{unit}.service"], check=False) + self.run(["systemctl", "reset-failed", f"{unit}.timer", f"{unit}.service"], check=False) + self.run(["systemd-run", f"--unit={unit}", f"--on-active={delay}", "--timer-property=AccuracySec=1s", self.helper_path, *args]) + + def network_apply(self, payload: Any) -> dict[str, Any]: + req = netconfig.validate_request(payload) + if self._pending(): + raise HelperError("Er staat al een netwerkwijziging open. Bevestig of draai die eerst terug.") + existing = self._existing_profile(req["interface"]) + uuid = existing.get("connection", "uuid", fallback=None) if existing else None + psk = "" + if req["interface"] == "wlan0" and not req["wifi"]["open"]: + psk = req["wifi"]["psk"] + if not psk and existing is not None: + old_ssid = netconfig.unescape(existing.get("wifi", "ssid", fallback="")) + if old_ssid == req["wifi"]["ssid"]: + psk = netconfig.unescape(existing.get("wifi-security", "psk", fallback="")) + if not psk: + raise HelperError("Vul het wifi-wachtwoord in.") + keyfile = netconfig.build_keyfile(req, uuid=uuid, psk=psk) + + active_before = self._active_uuids() + self.paths.network_state_dir.mkdir(parents=True, exist_ok=True, mode=0o755) + self._snapshot() + now = int(time.time()) + pending = { + "interface": req["interface"], + "method": req["method"], + "address": req.get("address", ""), + "created": now, + "deadline": now + netconfig.ROLLBACK_SECONDS, + "active_before": active_before, + } + try: + cfg.atomic_write_json(self.paths.network_pending_file, pending, mode=0o644, owner=(0, 0)) + cfg.atomic_write(self._keyfile_path(req["interface"]), keyfile.encode("utf-8"), mode=0o600, owner=(0, 0)) + self.run(["nmcli", "connection", "reload"]) + self._schedule(ROLLBACK_UNIT, netconfig.ROLLBACK_SECONDS, "network-restore") + self._schedule(ACTIVATE_UNIT, 2, "network-activate", req["interface"]) + except Exception: + self.network_restore() + raise + log(syslog.LOG_NOTICE, f"Netwerkwijziging voor {req['interface']} toegepast; rollback over {netconfig.ROLLBACK_SECONDS}s tenzij bevestigd") + return {"message": "Netwerkwijziging wordt toegepast.", "pending": pending} + + def network_activate(self, interface: str) -> dict[str, Any]: + if interface not in netconfig.INTERFACES: + raise HelperError("Onbekende interface") + proc = self.run(["nmcli", "--wait", "45", "connection", "up", "id", netconfig.profile_name(interface)], timeout=60, check=False) + if proc.returncode != 0: + log(syslog.LOG_WARNING, f"Activeren van {netconfig.profile_name(interface)} mislukt: {(proc.stderr or '').strip()}") + return {"activated": False} + log(syslog.LOG_INFO, f"{netconfig.profile_name(interface)} geactiveerd") + return {"activated": True} + + def network_confirm(self) -> dict[str, Any]: + if not self._pending(): + return {"message": "Er stond geen wijziging open."} + self.run(["systemctl", "stop", f"{ROLLBACK_UNIT}.timer"], check=False) + self.paths.network_pending_file.unlink(missing_ok=True) + shutil.rmtree(self.paths.network_backup_dir, ignore_errors=True) + log(syslog.LOG_NOTICE, "Netwerkwijziging bevestigd") + return {"message": "Netwerkwijziging bevestigd."} + + def network_rollback(self) -> dict[str, Any]: + """Public rollback: runs asynchronously so the HTTP response still arrives.""" + if not self._pending(): + return {"message": "Er stond geen wijziging open."} + self._schedule(ROLLBACK_UNIT, 1, "network-restore") + return {"message": "De vorige netwerkinstellingen worden teruggezet."} + + def _restore_files(self) -> bool: + backup = self.paths.network_backup_dir + if not backup.is_dir(): + log(syslog.LOG_ERR, "Geen netwerk-back-up gevonden; niets teruggezet") + return False + target = self.paths.nm_connections_dir + for item in target.iterdir(): + if item.is_file(): + item.unlink() + for item in backup.iterdir(): + shutil.copy2(item, target / item.name) + os.chmod(target / item.name, 0o600) + return True + + def network_restore(self, boot: bool = False) -> dict[str, Any]: + pending = self._pending() + if pending is None: + return {"message": "Er stond geen wijziging open."} + if not boot: + for iface in netconfig.INTERFACES: + self.run(["nmcli", "connection", "down", "id", netconfig.profile_name(iface)], check=False) + restored = self._restore_files() + if not boot and restored: + self.run(["nmcli", "connection", "reload"], check=False) + for uuid in pending.get("active_before", []): + if isinstance(uuid, str) and len(uuid) <= 64: + self.run(["nmcli", "--wait", "30", "connection", "up", "uuid", uuid], timeout=45, check=False) + self.paths.network_pending_file.unlink(missing_ok=True) + shutil.rmtree(self.paths.network_backup_dir, ignore_errors=True) + if not boot: + self.run(["systemctl", "stop", f"{ROLLBACK_UNIT}.timer"], check=False) + log(syslog.LOG_WARNING, "Netwerkwijziging teruggedraaid" + (" bij opstarten" if boot else "")) + return {"message": "Vorige netwerkinstellingen teruggezet."} + + def network_pending(self) -> dict[str, Any]: + return {"pending": self._pending()} + + def _boot_dir(self) -> Path | None: + for d in self.boot_dirs: + if (d / "config.txt").exists() or (d / "cmdline.txt").exists(): + return d + return None + + def network_boot_guard(self) -> dict[str, Any]: + """Before NetworkManager starts: undo an unconfirmed change and honour the reset trigger.""" + result: dict[str, Any] = {} + if self._pending(): + self.network_restore(boot=True) + result["rolled_back"] = True + boot = self._boot_dir() + trigger = boot / RESET_TRIGGER if boot else None + if trigger and trigger.exists(): + stash = self.paths.network_state_dir / f"disabled-{int(time.time())}" + stash.mkdir(parents=True, mode=0o700) + for iface in netconfig.INTERFACES: + src = self._keyfile_path(iface) + if src.exists(): + shutil.move(str(src), str(stash / src.name)) + trigger.unlink() + log(syslog.LOG_WARNING, f"Netwerk-reset uitgevoerd; VoxIP-profielen verplaatst naar {stash}") + result["reset"] = True + return result + + # -- provisioning from the boot partition ---------------------------- + + def _chown_config(self, path: Path) -> None: + try: + uid = pwd.getpwnam("voxip").pw_uid + gid = grp.getgrnam("kiosk").gr_gid + except KeyError: + return + os.chown(path, uid, gid) + os.chmod(path, 0o640) + + def boot_import(self) -> dict[str, Any]: + boot = self._boot_dir() + if boot is None: + return {"imported": []} + imported = [] + conf = boot / IMPORT_CONFIG + if conf.exists(): + try: + data = json.loads(conf.read_text(encoding="utf-8-sig")) + if not isinstance(data, dict): + raise HelperError("voxip-config.json moet een JSON-object zijn") + data = dict(data) + host = data.pop("hostname", None) + country = data.pop("wifi_country", None) + if data: + cfg.save_config(self.paths.config_file, data) + self._chown_config(self.paths.config_file) + if host: + self.set_hostname(str(host)) + if country: + self.set_wifi_country(str(country)) + conf.rename(conf.with_name(IMPORT_CONFIG + ".applied")) + imported.append(IMPORT_CONFIG) + log(syslog.LOG_NOTICE, "Configuratie geïmporteerd vanaf de bootpartitie") + except (HelperError, cfg.ConfigError, v.ValidationError, ValueError, OSError) as exc: + (boot / "voxip-config.error.txt").write_text(f"{exc}\n", encoding="utf-8") + conf.rename(conf.with_name(IMPORT_CONFIG + ".rejected")) + log(syslog.LOG_ERR, f"voxip-config.json geweigerd: {exc}") + keys = boot / IMPORT_KEYS + if keys.exists(): + try: + self._import_ssh_keys(keys.read_text(encoding="utf-8-sig")) + keys.rename(keys.with_name(IMPORT_KEYS + ".applied")) + imported.append(IMPORT_KEYS) + except (HelperError, OSError) as exc: + keys.rename(keys.with_name(IMPORT_KEYS + ".rejected")) + log(syslog.LOG_ERR, f"SSH-sleutels geweigerd: {exc}") + return {"imported": imported} + + def _import_ssh_keys(self, text: str) -> None: + keys = [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")] + if not keys or any(not k.startswith(SSH_KEY_TYPES) or len(k) > 8192 for k in keys): + raise HelperError("Bestand bevat geen geldige openbare SSH-sleutels") + user = next((u for u in pwd.getpwall() if u.pw_uid == 1000), None) + if user is None: + raise HelperError("Geen beheerdersaccount (uid 1000) gevonden") + ssh_dir = Path(user.pw_dir) / ".ssh" + ssh_dir.mkdir(mode=0o700, exist_ok=True) + auth = ssh_dir / "authorized_keys" + existing = auth.read_text(encoding="utf-8").splitlines() if auth.exists() else [] + merged = existing + [k for k in keys if k not in existing] + cfg.atomic_write(auth, ("\n".join(merged) + "\n").encode(), mode=0o600, owner=(user.pw_uid, user.pw_gid)) + os.chown(ssh_dir, user.pw_uid, user.pw_gid) + sshd_dir = self.etc / "ssh" / "sshd_config.d" + sshd_dir.mkdir(parents=True, exist_ok=True) + (sshd_dir / "voxip-kiosk.conf").write_text( + "# Beheerd door VoxIP Kiosk OS: alleen inloggen met SSH-sleutels.\n" + "PasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\n", + encoding="utf-8", + ) + self.run(["systemctl", "enable", "ssh.service"], check=False) + self.run(["systemctl", "start", "--no-block", "ssh.service"], check=False) + log(syslog.LOG_NOTICE, f"{len(keys)} SSH-sleutel(s) geïnstalleerd voor {user.pw_name}; SSH ingeschakeld") + + # -- dispatch --------------------------------------------------------- + + def dispatch(self, verb: str, args: list[str], stdin: str = "") -> dict[str, Any]: + def one_arg() -> str: + if len(args) != 1: + raise HelperError(f"{verb} verwacht precies één argument") + return args[0] + + def no_args() -> None: + if args: + raise HelperError(f"{verb} verwacht geen argumenten") + + table: dict[str, Callable[[], dict[str, Any]]] = { + "reboot": lambda: (no_args(), self.reboot())[1], + "poweroff": lambda: (no_args(), self.poweroff())[1], + "restart-browser": lambda: (no_args(), self.restart_browser())[1], + "restart-manager": lambda: (no_args(), self.restart_manager())[1], + "set-hostname": lambda: self.set_hostname(one_arg()), + "set-wifi-country": lambda: self.set_wifi_country(one_arg()), + "wifi-scan": lambda: (no_args(), self.wifi_scan())[1], + "network-apply": lambda: (no_args(), self.network_apply(_parse_json(stdin)))[1], + "network-activate": lambda: self.network_activate(one_arg()), + "network-confirm": lambda: (no_args(), self.network_confirm())[1], + "network-rollback": lambda: (no_args(), self.network_rollback())[1], + "network-restore": lambda: (no_args(), self.network_restore())[1], + "network-pending": lambda: (no_args(), self.network_pending())[1], + "network-boot-guard": lambda: (no_args(), self.network_boot_guard())[1], + "boot-import": lambda: (no_args(), self.boot_import())[1], + } + if verb not in table: + raise HelperError(f"Onbekende opdracht: {verb}") + return table[verb]() + + +def _parse_json(text: str) -> Any: + if len(text) > 16384: + raise HelperError("Invoer te groot") + try: + return json.loads(text) + except ValueError as exc: + raise HelperError("Ongeldige JSON-invoer") from exc + + +def main(argv: list[str] | None = None, helper: Helper | None = None, environ: dict[str, str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + environ = os.environ if environ is None else environ + syslog.openlog("voxip-kiosk-helper", 0, syslog.LOG_DAEMON) + if not argv: + print(json.dumps({"ok": False, "error": "Gebruik: voxip-kiosk-helper [argumenten]"})) + return 2 + verb, args = argv[0], argv[1:] + caller = environ.get("SUDO_USER") + if caller and verb not in PUBLIC_VERBS: + log(syslog.LOG_WARNING, f"Geweigerd: {caller} probeerde interne opdracht {verb}") + print(json.dumps({"ok": False, "error": "Opdracht niet toegestaan"})) + return 1 + if helper is None and os.geteuid() != 0: + print(json.dumps({"ok": False, "error": "voxip-kiosk-helper moet als root draaien"})) + return 1 + helper = helper or Helper() + stdin = sys.stdin.read(16385) if verb == "network-apply" else "" + try: + result = helper.dispatch(verb, args, stdin) + except (HelperError, v.ValidationError, cfg.ConfigError) as exc: + log(syslog.LOG_WARNING, f"{verb} mislukt: {exc}") + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + return 1 + print(json.dumps({"ok": True, **result}, ensure_ascii=False)) + return 0 diff --git a/src/voxip_kiosk/helper_client.py b/src/voxip_kiosk/helper_client.py new file mode 100644 index 0000000..bd1a528 --- /dev/null +++ b/src/voxip_kiosk/helper_client.py @@ -0,0 +1,37 @@ +"""Calls the root helper through ``sudo -n`` with a fixed argument list.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + + +class HelperError(Exception): + pass + + +class HelperClient: + def __init__(self, helper_path: Path): + self.helper_path = str(helper_path) + + def call(self, verb: str, *args: str, payload: Any = None, timeout: float = 60) -> dict[str, Any]: + cmd = ["sudo", "-n", self.helper_path, verb, *args] + try: + proc = subprocess.run( + cmd, input=json.dumps(payload) if payload is not None else "", + capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise HelperError("De opdracht duurde te lang.") from None + except OSError as exc: + raise HelperError(f"Systeemhelper niet beschikbaar: {exc}") from None + try: + result = json.loads(proc.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + detail = (proc.stderr or "").strip().splitlines() + raise HelperError(detail[-1] if detail else "Onverwacht antwoord van de systeemhelper.") from None + if not result.get("ok"): + raise HelperError(result.get("error") or "Opdracht mislukt.") + return result diff --git a/src/voxip_kiosk/logutil.py b/src/voxip_kiosk/logutil.py new file mode 100644 index 0000000..aa4a1cd --- /dev/null +++ b/src/voxip_kiosk/logutil.py @@ -0,0 +1,23 @@ +"""Logging to stdout with sd-daemon priority prefixes, so journald records +warnings and errors at the right level (``journalctl -p warning`` works).""" + +from __future__ import annotations + +import logging +import sys + +_PRIORITY = {logging.DEBUG: 7, logging.INFO: 6, logging.WARNING: 4, logging.ERROR: 3, logging.CRITICAL: 2} + + +class JournalFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + prio = _PRIORITY.get(record.levelno, 6) + return "\n".join(f"<{prio}>{line}" for line in super().format(record).splitlines() or [""]) + + +def setup(level: int = logging.INFO) -> None: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JournalFormatter("%(message)s")) + root = logging.getLogger() + root.handlers[:] = [handler] + root.setLevel(level) diff --git a/src/voxip_kiosk/manager.py b/src/voxip_kiosk/manager.py new file mode 100644 index 0000000..bb78fcd --- /dev/null +++ b/src/voxip_kiosk/manager.py @@ -0,0 +1,69 @@ +"""voxip-kiosk-manager — starts the web interface under gunicorn. + +Reads port and HTTPS settings from the kiosk configuration, creates a +self-signed certificate when HTTPS is enabled and none exists yet, then +replaces itself with gunicorn (one worker so the login throttle is shared). +""" + +from __future__ import annotations + +import logging +import os +import shutil +import socket +import subprocess +import sys +from pathlib import Path + +from . import logutil +from .config import load_config +from .paths import Paths, load_paths + +log = logging.getLogger("voxip-kiosk-manager") +PYTHON_LIB = "/usr/lib/voxip-kiosk/python" + + +def ensure_certificate(paths: Paths) -> tuple[Path, Path]: + cert, key = paths.tls_dir / "cert.pem", paths.tls_dir / "key.pem" + if cert.exists() and key.exists(): + return cert, key + paths.tls_dir.mkdir(mode=0o700, exist_ok=True) + host = socket.gethostname() + log.info("Zelfondertekend certificaat aanmaken voor %s.local", host) + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650", + "-subj", f"/CN={host}.local/O=VoxIP Kiosk OS", + "-addext", f"subjectAltName=DNS:{host}.local,DNS:{host},IP:127.0.0.1", + "-keyout", str(key), "-out", str(cert), + ], check=True, capture_output=True, timeout=120) + os.chmod(key, 0o600) + return cert, key + + +def gunicorn_argv(paths: Paths, config: dict) -> list[str]: + binary = shutil.which("gunicorn") or shutil.which("gunicorn3") + argv = [binary] if binary else [sys.executable, "-m", "gunicorn"] + bind = f"[::]:{config['admin_port']}" if Path("/proc/net/if_inet6").exists() else f"0.0.0.0:{config['admin_port']}" + argv += [ + "--workers", "1", "--threads", "4", "--worker-class", "gthread", + "--bind", bind, + "--timeout", "90", "--graceful-timeout", "10", "--keep-alive", "5", + "--worker-tmp-dir", "/dev/shm", + "--error-logfile", "-", "--log-level", "warning", + "--pythonpath", PYTHON_LIB, + ] + if config["admin_https"]: + cert, key = ensure_certificate(paths) + argv += ["--certfile", str(cert), "--keyfile", str(key)] + argv.append("voxip_kiosk.web:create_app()") + return argv + + +def main() -> int: + logutil.setup() + paths = load_paths() + config = load_config(paths.config_file) + argv = gunicorn_argv(paths, config) + log.info("VoxIP Kiosk Manager start op poort %s (%s)", config["admin_port"], "HTTPS" if config["admin_https"] else "HTTP") + os.execv(argv[0], argv) + return 1 # not reached diff --git a/src/voxip_kiosk/netconfig.py b/src/voxip_kiosk/netconfig.py new file mode 100644 index 0000000..17e9274 --- /dev/null +++ b/src/voxip_kiosk/netconfig.py @@ -0,0 +1,181 @@ +"""NetworkManager configuration model used by the root helper. + +The helper writes keyfiles itself instead of passing settings to ``nmcli`` +on the command line: secrets never show up in the process list, and a +rollback is a plain file restore. +""" + +from __future__ import annotations + +import configparser +import io +import uuid as uuidlib +from typing import Any + +from . import validation as v + +INTERFACES = {"eth0": "ethernet", "wlan0": "wifi"} +PROFILE_PREFIX = "voxip-" +ROLLBACK_SECONDS = 180 + + +def profile_name(interface: str) -> str: + return PROFILE_PREFIX + interface + + +def validate_request(payload: Any) -> dict[str, Any]: + """Validate a network change request coming from the web interface.""" + if not isinstance(payload, dict): + raise v.ValidationError("Ongeldig verzoek.") + interface = payload.get("interface") + if interface not in INTERFACES: + raise v.ValidationError("Onbekende netwerkinterface.") + method = payload.get("method") + if method not in ("dhcp", "static"): + raise v.ValidationError("Kies DHCP of een statisch adres.") + clean: dict[str, Any] = {"interface": interface, "method": method} + if method == "static": + clean["address"] = v.ipv4_interface(str(payload.get("address", ""))) + gateway = str(payload.get("gateway", "")).strip() + if gateway: + import ipaddress + + gw = v.ipv4_address(gateway) + if ipaddress.IPv4Address(gw) not in ipaddress.IPv4Interface(clean["address"]).network: + raise v.ValidationError("De gateway ligt niet in hetzelfde subnet als het IP-adres.") + clean["gateway"] = gw + else: + clean["gateway"] = "" + dns = payload.get("dns", []) + if not isinstance(dns, (list, str)): + raise v.ValidationError("Ongeldige DNS-servers.") + clean["dns"] = v.dns_servers(dns) + if method == "static" and not clean["dns"]: + raise v.ValidationError("Vul bij een statisch adres minimaal één DNS-server in.") + if interface == "wlan0": + wifi = payload.get("wifi") + if not isinstance(wifi, dict): + raise v.ValidationError("Wifi-gegevens ontbreken.") + psk = wifi.get("psk") or "" + clean["wifi"] = { + "ssid": v.ssid(str(wifi.get("ssid", ""))), + "psk": v.wifi_psk(psk) if psk else "", + "hidden": bool(wifi.get("hidden", False)), + "open": bool(wifi.get("open", False)), + } + return clean + + +def _escape(value: str) -> str: + """Escape a value for a GLib key file (the format NetworkManager uses).""" + out = value.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r") + if out.startswith(" "): + out = "\\s" + out[1:] + return out + + +def build_keyfile(req: dict[str, Any], uuid: str | None = None, psk: str = "") -> str: + """Render a NetworkManager keyfile for a validated request.""" + interface = req["interface"] + name = profile_name(interface) + lines = [ + "[connection]", + f"id={name}", + f"uuid={uuid or uuidlib.uuid4()}", + f"type={INTERFACES[interface]}", + f"interface-name={interface}", + "autoconnect=true", + "autoconnect-priority=100", + "", + ] + if interface == "wlan0": + wifi = req["wifi"] + lines += ["[wifi]", "mode=infrastructure", f"ssid={_escape(wifi['ssid'])}", f"hidden={'true' if wifi['hidden'] else 'false'}", ""] + if not wifi["open"]: + if not psk: + raise v.ValidationError("Vul het wifi-wachtwoord in.") + lines += ["[wifi-security]", "key-mgmt=wpa-psk", f"psk={_escape(psk)}", ""] + else: + lines += ["[ethernet]", ""] + lines.append("[ipv4]") + if req["method"] == "static": + lines.append("method=manual") + address = req["address"] + lines.append(f"address1={address},{req['gateway']}" if req.get("gateway") else f"address1={address}") + else: + lines.append("method=auto") + if req["dns"]: + ipv4_dns = [d for d in req["dns"] if ":" not in d] + if ipv4_dns: + lines.append("dns=" + ";".join(ipv4_dns) + ";") + lines.append("ignore-auto-dns=true") + lines += ["", "[ipv6]", "addr-gen-mode=default", "method=auto"] + ipv6_dns = [d for d in req["dns"] if ":" in d] + if ipv6_dns: + lines.append("dns=" + ";".join(ipv6_dns) + ";") + lines += ["", "[proxy]", ""] + return "\n".join(lines) + + +def read_keyfile(text: str) -> configparser.ConfigParser: + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.optionxform = str # keep key case + parser.read_file(io.StringIO(text)) + return parser + + +def unescape(value: str) -> str: + out, i = [], 0 + mapping = {"\\": "\\", "n": "\n", "t": "\t", "r": "\r", "s": " "} + while i < len(value): + ch = value[i] + if ch == "\\" and i + 1 < len(value) and value[i + 1] in mapping: + out.append(mapping[value[i + 1]]) + i += 2 + else: + out.append(ch) + i += 1 + return "".join(out) + + +def split_terse(line: str) -> list[str]: + """Split one line of ``nmcli -t`` output, honouring ``\\:`` and ``\\\\`` escapes.""" + fields, current, i = [], [], 0 + while i < len(line): + ch = line[i] + if ch == "\\" and i + 1 < len(line): + current.append(line[i + 1]) + i += 2 + continue + if ch == ":": + fields.append("".join(current)) + current = [] + else: + current.append(ch) + i += 1 + fields.append("".join(current)) + return fields + + +def parse_wifi_scan(output: str) -> list[dict[str, Any]]: + """Parse ``nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list``.""" + networks: dict[str, dict[str, Any]] = {} + for line in output.splitlines(): + fields = split_terse(line) + if len(fields) < 4: + continue + in_use, ssid, signal, security = fields[:4] + if not ssid: + continue + try: + strength = int(signal) + except ValueError: + strength = 0 + entry = {"ssid": ssid, "signal": strength, "security": security.strip() or "open", "in_use": in_use.strip() == "*"} + current = networks.get(ssid) + if current is None or strength > current["signal"]: + entry["in_use"] = entry["in_use"] or bool(current and current["in_use"]) + networks[ssid] = entry + elif entry["in_use"]: + current["in_use"] = True + return sorted(networks.values(), key=lambda n: (-n["in_use"], -n["signal"], n["ssid"].lower())) diff --git a/src/voxip_kiosk/netinfo.py b/src/voxip_kiosk/netinfo.py new file mode 100644 index 0000000..b871c7d --- /dev/null +++ b/src/voxip_kiosk/netinfo.py @@ -0,0 +1,127 @@ +"""Network status for the web interface (read-only, no privileges needed).""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from .netconfig import INTERFACES, profile_name, split_terse + +STATE_LABELS = { + "connected": "Verbonden", + "connecting": "Bezig met verbinden", + "disconnected": "Niet verbonden", + "unavailable": "Niet beschikbaar", + "unmanaged": "Niet beheerd", +} + + +def _nmcli(*args: str) -> str | None: + if not shutil.which("nmcli"): + return None + try: + proc = subprocess.run(["nmcli", *args], capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return None + return proc.stdout if proc.returncode == 0 else None + + +def device_details(device: str) -> dict[str, Any]: + out = _nmcli("-t", "device", "show", device) + info: dict[str, Any] = {"addresses": [], "dns": [], "gateway": "", "hwaddr": "", "state": "", "connection": ""} + if not out: + return info + for line in out.splitlines(): + key, _, value = line.partition(":") + value = value.replace("\\:", ":") + base = key.split("[", 1)[0] + if base == "IP4.ADDRESS" and value: + info["addresses"].append(value) + elif base == "IP4.DNS" and value: + info["dns"].append(value) + elif base == "IP4.GATEWAY" and value and value != "--": + info["gateway"] = value + elif base == "GENERAL.HWADDR": + info["hwaddr"] = value + elif base == "GENERAL.STATE": + info["state"] = re.sub(r"^\d+\s*", "", value).strip("() ") + elif base == "GENERAL.CONNECTION" and value != "--": + info["connection"] = value + return info + + +def profile_settings(interface: str) -> dict[str, Any]: + """Current settings of our own profile, used to pre-fill the forms.""" + out = _nmcli("-t", "-f", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,802-11-wireless.ssid,802-11-wireless.hidden", + "connection", "show", profile_name(interface)) + settings = {"exists": False, "method": "dhcp", "address": "", "gateway": "", "dns": "", "ssid": "", "hidden": False} + if not out: + return settings + settings["exists"] = True + for line in out.splitlines(): + key, _, value = line.partition(":") + value = value.replace("\\:", ":") + if value == "--": + value = "" + if key == "ipv4.method": + settings["method"] = "static" if value == "manual" else "dhcp" + elif key == "ipv4.addresses": + settings["address"] = value.split(",")[0].strip() + elif key == "ipv4.gateway": + settings["gateway"] = value + elif key == "ipv4.dns": + settings["dns"] = ", ".join(x for x in value.split(",") if x) + elif key == "802-11-wireless.ssid": + settings["ssid"] = value + elif key == "802-11-wireless.hidden": + settings["hidden"] = value == "yes" + return settings + + +def overview() -> dict[str, Any]: + available = shutil.which("nmcli") is not None + devices = {} + status = _nmcli("-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status") or "" + present = {split_terse(line)[0]: split_terse(line) for line in status.splitlines() if line} + for iface, kind in INTERFACES.items(): + fields = present.get(iface) + details = device_details(iface) if fields else {} + state = fields[2] if fields and len(fields) > 2 else "" + devices[iface] = { + "interface": iface, + "kind": kind, + "present": fields is not None, + "state": state, + "state_label": STATE_LABELS.get(state, state or "Niet aanwezig"), + "connected": state == "connected", + "connection": fields[3] if fields and len(fields) > 3 and fields[3] != "--" else "", + "addresses": details.get("addresses", []), + "gateway": details.get("gateway", ""), + "dns": details.get("dns", []), + "hwaddr": details.get("hwaddr", ""), + "profile": profile_settings(iface) if available else {}, + } + return {"available": available, "devices": devices, "wifi_country": wifi_country()} + + +def wifi_country() -> str: + if not shutil.which("iw"): + return "" + try: + out = subprocess.run(["iw", "reg", "get"], capture_output=True, text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return "" + match = re.search(r"country ([A-Z0-9]{2}):", out) + return match.group(1) if match and match.group(1) != "00" else "" + + +def pending_change(pending_file: Path) -> dict[str, Any] | None: + try: + data = json.loads(pending_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None diff --git a/src/voxip_kiosk/paths.py b/src/voxip_kiosk/paths.py new file mode 100644 index 0000000..8a92888 --- /dev/null +++ b/src/voxip_kiosk/paths.py @@ -0,0 +1,114 @@ +"""Filesystem locations used by every VoxIP Kiosk component. + +The defaults match the installed layout. Unprivileged components may +override them through ``VOXIP_*`` environment variables (used by the test +suite and for local development). The root helper always uses +:func:`default_paths` and never reads the environment. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Paths: + config_dir: Path = Path("/etc/voxip-kiosk") + state_dir: Path = Path("/var/lib/voxip-kiosk") + network_state_dir: Path = Path("/var/lib/voxip-kiosk-network") + agent_run_dir: Path = Path("/run/voxip-kiosk-agent") + browser_run_dir: Path = Path("/run/voxip-kiosk-browser") + health_run_dir: Path = Path("/run/voxip-kiosk-health") + share_dir: Path = Path("/usr/share/voxip-kiosk") + log_dir: Path = Path("/var/log/voxip-kiosk") + helper: Path = Path("/usr/lib/voxip-kiosk/voxip-kiosk-helper") + nm_connections_dir: Path = Path("/etc/NetworkManager/system-connections") + + @property + def config_file(self) -> Path: + return self.config_dir / "config.json" + + @property + def admin_file(self) -> Path: + return self.config_dir / "admin.json" + + @property + def setup_code_file(self) -> Path: + # Lives in the config dir (group kiosk) so the agent can show it on + # the HDMI screen during initial setup. + return self.config_dir / "setup-code" + + @property + def tls_dir(self) -> Path: + return self.config_dir / "tls" + + @property + def secret_key_file(self) -> Path: + return self.state_dir / "secret_key" + + @property + def network_pending_file(self) -> Path: + return self.network_state_dir / "pending.json" + + @property + def network_backup_dir(self) -> Path: + return self.network_state_dir / "backup" + + @property + def agent_state_file(self) -> Path: + return self.agent_run_dir / "state.json" + + @property + def display_info_file(self) -> Path: + return self.browser_run_dir / "display.json" + + @property + def health_state_file(self) -> Path: + return self.health_run_dir / "state.json" + + @property + def www_dir(self) -> Path: + return self.share_dir / "www" + + @property + def version_file(self) -> Path: + return self.share_dir / "VERSION" + + @property + def audit_log(self) -> Path: + return self.log_dir / "audit.log" + + +def default_paths() -> Paths: + return Paths() + + +_ENV = { + "config_dir": "VOXIP_CONFIG_DIR", + "state_dir": "VOXIP_STATE_DIR", + "network_state_dir": "VOXIP_NETWORK_STATE_DIR", + "agent_run_dir": "VOXIP_AGENT_RUN_DIR", + "browser_run_dir": "VOXIP_BROWSER_RUN_DIR", + "health_run_dir": "VOXIP_HEALTH_RUN_DIR", + "share_dir": "VOXIP_SHARE_DIR", + "log_dir": "VOXIP_LOG_DIR", + "helper": "VOXIP_HELPER", +} + + +def load_paths() -> Paths: + overrides = {field: Path(os.environ[env]) for field, env in _ENV.items() if os.environ.get(env)} + return Paths(**overrides) + + +def read_version(paths: Paths) -> str: + try: + return paths.version_file.read_text(encoding="utf-8").strip() or "onbekend" + except OSError: + here = Path(__file__).resolve().parents[2] / "VERSION" + try: + return here.read_text(encoding="utf-8").strip() + "-dev" + except OSError: + return "dev" diff --git a/src/voxip_kiosk/services.py b/src/voxip_kiosk/services.py new file mode 100644 index 0000000..fc872cc --- /dev/null +++ b/src/voxip_kiosk/services.py @@ -0,0 +1,100 @@ +"""Read-only access to systemd unit state and the journal.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +BROWSER_UNIT = "voxip-kiosk-browser.service" +X_UNIT = "voxip-kiosk-x.service" +AGENT_UNIT = "voxip-kiosk-agent.service" +MANAGER_UNIT = "voxip-kiosk-manager.service" +HEALTH_UNIT = "voxip-kiosk-healthcheck.service" + +# Units whose logs the web interface may show (label → journalctl -u args). +LOG_SOURCES: dict[str, tuple[str, list[str]]] = { + "kiosk": ("Kiosk (Chromium, X, agent)", [BROWSER_UNIT, X_UNIT, AGENT_UNIT]), + "browser": ("Chromium", [BROWSER_UNIT]), + "agent": ("Kiosk-agent", [AGENT_UNIT]), + "health": ("Healthcheck", [HEALTH_UNIT]), + "manager": ("Beheerinterface", [MANAGER_UNIT]), + "network": ("Netwerk", ["NetworkManager.service", "voxip-network-guard.service", "voxip-network-rollback.service", "voxip-network-activate.service"]), + "system": ("Volledig systeem", []), +} +LOG_LINES = (50, 100, 200, 500) + +_SHOW_PROPS = "ActiveState,SubState,Result,NRestarts,ActiveEnterTimestamp,ExecMainStatus,LoadState" + + +def unit_status(unit: str) -> dict[str, str]: + try: + out = subprocess.run( + ["systemctl", "show", unit, "--no-pager", "-p", _SHOW_PROPS], + capture_output=True, text=True, timeout=5, + ).stdout + except (OSError, subprocess.SubprocessError): + return {} + return parse_show(out) + + +def parse_show(output: str) -> dict[str, str]: + props = {} + for line in output.splitlines(): + key, sep, value = line.partition("=") + if sep: + props[key] = value + return props + + +def describe(props: dict[str, str]) -> tuple[str, str]: + """(status, Dutch label) for a unit: status ∈ ok / starting / stopped / error / unknown.""" + active = props.get("ActiveState") + if not active: + return "unknown", "Onbekend" + if active == "active": + return "ok", "Actief" + if active in ("activating", "reloading"): + return "starting", "Bezig met starten" + if active == "failed": + return "error", "Fout" + if active == "deactivating": + return "starting", "Bezig met stoppen" + if props.get("Result", "success") not in ("success", ""): + return "error", "Gestopt na fout" + return "stopped", "Gestopt" + + +def journal(source: str, lines: int = 100, priority: str | None = None) -> str: + if source not in LOG_SOURCES: + raise ValueError("onbekende logbron") + lines = lines if lines in LOG_LINES else 100 + cmd = ["journalctl", "--no-pager", "-o", "short-iso", "-n", str(lines)] + for unit in LOG_SOURCES[source][1]: + cmd += ["-u", unit] + if priority in ("err", "warning"): + cmd += ["-p", priority] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError) as exc: + return f"Logs konden niet worden gelezen: {exc}" + return proc.stdout or proc.stderr or "(geen regels)" + + +def recent_errors(lines: int = 20) -> list[str]: + """Warnings and errors logged by the kiosk agent, browser launcher and healthcheck.""" + cmd = ["journalctl", "--no-pager", "-o", "short-iso", "-n", str(lines), "-p", "warning", + "-u", AGENT_UNIT, "-u", BROWSER_UNIT, "-u", HEALTH_UNIT, "-u", X_UNIT] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.SubprocessError): + return [] + return [line for line in out.splitlines() if line and not line.startswith("-- ")] + + +def summary() -> dict[str, Any]: + result = {} + for key, unit in (("browser", BROWSER_UNIT), ("x", X_UNIT), ("agent", AGENT_UNIT), ("manager", MANAGER_UNIT)): + props = unit_status(unit) + status, label = describe(props) + result[key] = {"unit": unit, "status": status, "label": label, "restarts": props.get("NRestarts", "0"), "since": props.get("ActiveEnterTimestamp", "")} + return result diff --git a/src/voxip_kiosk/setupcode.py b/src/voxip_kiosk/setupcode.py new file mode 100644 index 0000000..340554d --- /dev/null +++ b/src/voxip_kiosk/setupcode.py @@ -0,0 +1,42 @@ +"""One-time setup code that guards the initial admin password.""" + +from __future__ import annotations + +import hmac +import os +import secrets +from pathlib import Path + +ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I confusion on screen + + +def generate() -> str: + raw = "".join(secrets.choice(ALPHABET) for _ in range(8)) + return f"{raw[:4]}-{raw[4:]}" + + +def normalise(code: str) -> str: + cleaned = "".join(ch for ch in (code or "").upper() if ch.isalnum()) + return f"{cleaned[:4]}-{cleaned[4:]}" if len(cleaned) == 8 else cleaned + + +def write(path: Path, code: str) -> None: + """Store the code readable for group kiosk (the setup screen) only.""" + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(code + "\n") + os.chmod(path, 0o640) + + +def read(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8").strip() or None + except OSError: + return None + + +def matches(path: Path, candidate: str) -> bool: + stored = read(path) + if not stored: + return False + return hmac.compare_digest(stored.encode(), normalise(candidate).encode()) diff --git a/src/voxip_kiosk/sysinfo.py b/src/voxip_kiosk/sysinfo.py new file mode 100644 index 0000000..56d6109 --- /dev/null +++ b/src/voxip_kiosk/sysinfo.py @@ -0,0 +1,201 @@ +"""System metrics for the dashboard, read without any privileges.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import threading +import time +from pathlib import Path +from typing import Any + +_cpu_lock = threading.Lock() +_cpu_prev: tuple[int, int] | None = None + + +def _read(path: str) -> str | None: + try: + return Path(path).read_text(encoding="utf-8") + except OSError: + return None + + +def uptime_seconds(proc: str = "/proc") -> float | None: + raw = _read(f"{proc}/uptime") + try: + return float(raw.split()[0]) if raw else None + except (ValueError, IndexError): + return None + + +def load_average() -> tuple[float, float, float] | None: + try: + return os.getloadavg() + except OSError: + return None + + +def _cpu_times(proc: str) -> tuple[int, int] | None: + raw = _read(f"{proc}/stat") + if not raw: + return None + fields = raw.splitlines()[0].split() + if not fields or fields[0] != "cpu": + return None + values = [int(x) for x in fields[1:]] + idle = values[3] + (values[4] if len(values) > 4 else 0) + return sum(values), idle + + +def cpu_percent(proc: str = "/proc") -> float | None: + """CPU usage since the previous call (first call: since boot).""" + global _cpu_prev + now = _cpu_times(proc) + if now is None: + return None + with _cpu_lock: + prev, _cpu_prev = _cpu_prev, now + total, idle = now + if prev is not None and total > prev[0]: + total, idle = total - prev[0], idle - prev[1] + if total <= 0: + return None + return round(100.0 * (1 - idle / total), 1) + + +def temperature_c(sys: str = "/sys") -> float | None: + raw = _read(f"{sys}/class/thermal/thermal_zone0/temp") + try: + return round(int(raw.strip()) / 1000, 1) if raw else None + except ValueError: + return None + + +def memory(proc: str = "/proc") -> dict[str, Any] | None: + raw = _read(f"{proc}/meminfo") + if not raw: + return None + info = {} + for line in raw.splitlines(): + key, _, rest = line.partition(":") + try: + info[key] = int(rest.split()[0]) * 1024 + except (ValueError, IndexError): + continue + total = info.get("MemTotal") + avail = info.get("MemAvailable") + if not total or avail is None: + return None + used = total - avail + return {"total": total, "used": used, "percent": round(100 * used / total, 1)} + + +def disk(path: str = "/") -> dict[str, Any] | None: + try: + usage = shutil.disk_usage(path) + except OSError: + return None + return {"total": usage.total, "used": usage.used, "percent": round(100 * usage.used / usage.total, 1)} + + +THROTTLE_FLAGS = { + 0: "Onderspanning", + 1: "Frequentie begrensd", + 2: "Afgeknepen (throttled)", + 3: "Temperatuurlimiet", + 16: "Onderspanning opgetreden", + 17: "Frequentiebegrenzing opgetreden", + 18: "Afknijpen opgetreden", + 19: "Temperatuurlimiet opgetreden", +} + + +def parse_throttled(output: str) -> dict[str, Any] | None: + _, _, value = output.strip().partition("=") + try: + bits = int(value, 16) + except ValueError: + return None + flags = [label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << bit)] + return {"raw": hex(bits), "now": bool(bits & 0xF), "ever": bool(bits & 0xF0000), "flags": flags} + + +def throttled() -> dict[str, Any] | None: + """Power/thermal throttling as reported by the firmware (needs group video).""" + if not shutil.which("vcgencmd"): + return None + try: + out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True, text=True, timeout=3, check=True).stdout + except (OSError, subprocess.SubprocessError): + return None + return parse_throttled(out) + + +def ip_addresses() -> dict[str, list[str]]: + """Global addresses per interface, e.g. ``{"eth0": ["192.168.1.20/24"]}``.""" + try: + out = subprocess.run(["ip", "-j", "addr", "show"], capture_output=True, text=True, timeout=3, check=True).stdout + data = json.loads(out) + except (OSError, subprocess.SubprocessError, ValueError): + return {} + return parse_ip_json(data) + + +def parse_ip_json(data: list[dict[str, Any]]) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for iface in data: + name = iface.get("ifname", "") + if name == "lo": + continue + addrs = [ + f"{a['local']}/{a['prefixlen']}" + for a in iface.get("addr_info", []) + if a.get("scope") == "global" and "local" in a + ] + if addrs: + result[name] = addrs + return result + + +def os_release() -> str: + raw = _read("/etc/os-release") or "" + for line in raw.splitlines(): + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + return "onbekend" + + +def model() -> str: + raw = _read("/proc/device-tree/model") + return raw.strip("\x00\n ") if raw else "onbekend" + + +def format_duration(seconds: float | None) -> str: + if seconds is None: + return "–" + seconds = int(seconds) + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + if days: + return f"{days} d {hours} u {minutes} min" + if hours: + return f"{hours} u {minutes} min" + return f"{minutes} min" + + +def format_bytes(value: int | None) -> str: + if value is None: + return "–" + size = float(value) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + return f"{size:.0f} {unit}" if unit in ("B", "KB") else f"{size:.1f} {unit}".replace(".", ",") + size /= 1024 + return f"{value} B" + + +def timestamp() -> float: + return time.time() diff --git a/src/voxip_kiosk/validation.py b/src/voxip_kiosk/validation.py new file mode 100644 index 0000000..11eb622 --- /dev/null +++ b/src/voxip_kiosk/validation.py @@ -0,0 +1,195 @@ +"""Input validation shared by the web interface, the config loader and the +root helper. Every validator either returns a normalised value or raises +:class:`ValidationError` with a Dutch message suitable for the UI. +""" + +from __future__ import annotations + +import ipaddress +import re +from urllib.parse import unquote_plus, urlsplit, urlunsplit + + +class ValidationError(ValueError): + pass + + +_CONTROL = re.compile(r"[\x00-\x1f\x7f]") +_HOSTNAME = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") +_RESOLUTION = re.compile(r"^(\d{3,4})x(\d{3,4})(?:@(\d{2,3}(?:\.\d{1,3})?))?$") +_COUNTRY = re.compile(r"^[A-Z]{2}$") +_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") + +# Query parameters whose values are secrets (InfoFlow display URLs carry +# their access token as ?token=...). They are masked wherever a URL is shown +# or logged. +SECRET_PARAMS = {"token", "key", "apikey", "api_key", "secret", "password", "auth", "access_token"} + + +def kiosk_url(value: str) -> str: + """A kiosk URL: HTTPS only, with a host, without embedded credentials.""" + if not isinstance(value, str): + raise ValidationError("Ongeldige URL.") + url = value.strip() + if not url: + raise ValidationError("Vul een URL in.") + if len(url) > 2048: + raise ValidationError("De URL is te lang (maximaal 2048 tekens).") + if _CONTROL.search(url) or " " in url: + raise ValidationError("De URL mag geen spaties of stuurtekens bevatten.") + try: + parts = urlsplit(url) + port = parts.port + except ValueError: + raise ValidationError("De URL is ongeldig.") from None + if parts.scheme.lower() != "https": + raise ValidationError("Alleen HTTPS-URL's zijn toegestaan (https://…).") + if not parts.hostname: + raise ValidationError("De URL bevat geen hostnaam.") + if parts.username or parts.password: + raise ValidationError("Gebruikersnaam/wachtwoord in de URL is niet toegestaan.") + if port is not None and not 0 < port < 65536: + raise ValidationError("Ongeldige poort in de URL.") + host = parts.hostname + try: + ipaddress.ip_address(host) + except ValueError: + if not all(_HOSTNAME.match(label) for label in host.lower().rstrip(".").split(".")): + raise ValidationError("De hostnaam in de URL is ongeldig.") from None + return url + + +def mask_url(url: str) -> str: + """Hide secret query parameter values: ``?token=abcdef1234`` → ``?token=…1234``.""" + try: + parts = urlsplit(url) + except ValueError: + return url + if not parts.query: + return url + pairs = [] + for pair in parts.query.split("&"): + key, sep, val = pair.partition("=") + if sep and val and unquote_plus(key).lower() in SECRET_PARAMS: + val = "…" + val[-4:] if len(val) > 8 else "…" + pairs.append(key + sep + val) + return urlunsplit(parts._replace(query="&".join(pairs))) + + +def hostname(value: str) -> str: + name = (value or "").strip().lower() + if not _HOSTNAME.match(name): + raise ValidationError( + "Een hostname bestaat uit 1–63 tekens: kleine letters, cijfers en " + "koppeltekens, niet beginnend of eindigend met een koppelteken." + ) + if name == "localhost": + raise ValidationError("'localhost' kan niet als hostname worden gebruikt.") + return name + + +def device_name(value: str) -> str: + name = (value or "").strip() + if not name: + raise ValidationError("Vul een apparaatnaam in.") + if len(name) > 64 or _CONTROL.search(name): + raise ValidationError("De apparaatnaam mag maximaal 64 tekens bevatten, zonder stuurtekens.") + return name + + +def resolution(value: str) -> str: + val = (value or "").strip().lower() + if val == "auto": + return val + match = _RESOLUTION.match(val) + if not match: + raise ValidationError("Gebruik 'auto' of een resolutie als 1920x1080 of 1920x1080@60.") + width, height = int(match.group(1)), int(match.group(2)) + if not (320 <= width <= 4096 and 200 <= height <= 4096): + raise ValidationError("Resolutie buiten het ondersteunde bereik.") + return val + + +def parse_resolution(value: str) -> tuple[str, str | None] | None: + """``"1920x1080@60"`` → ``("1920x1080", "60")``; ``"auto"`` → ``None``.""" + if value == "auto": + return None + match = _RESOLUTION.match(value) + if not match: + return None + return f"{match.group(1)}x{match.group(2)}", match.group(3) + + +def ipv4_address(value: str) -> str: + try: + addr = ipaddress.IPv4Address((value or "").strip()) + except ValueError: + raise ValidationError(f"'{value}' is geen geldig IPv4-adres.") from None + if addr.is_multicast or addr.is_unspecified or addr.is_loopback: + raise ValidationError(f"'{value}' kan hier niet gebruikt worden.") + return str(addr) + + +def ipv4_interface(value: str) -> str: + """``192.168.1.50/24`` (a host address with prefix length).""" + raw = (value or "").strip() + if "/" not in raw: + raise ValidationError("Vul het IP-adres in met prefixlengte, bijvoorbeeld 192.168.1.50/24.") + try: + iface = ipaddress.IPv4Interface(raw) + except ValueError: + raise ValidationError(f"'{raw}' is geen geldig IPv4-adres met prefix.") from None + if not 8 <= iface.network.prefixlen <= 32: + raise ValidationError("De prefixlengte moet tussen 8 en 32 liggen.") + if iface.network.prefixlen < 31 and iface.ip in (iface.network.network_address, iface.network.broadcast_address): + raise ValidationError("Het adres is een netwerk- of broadcastadres.") + ipv4_address(str(iface.ip)) + return iface.with_prefixlen + + +def dns_servers(values: list[str] | str) -> list[str]: + if isinstance(values, str): + values = re.split(r"[\s,;]+", values) + result: list[str] = [] + for raw in values: + raw = raw.strip() + if not raw: + continue + try: + addr = ipaddress.ip_address(raw) + except ValueError: + raise ValidationError(f"'{raw}' is geen geldig DNS-serveradres.") from None + if str(addr) not in result: + result.append(str(addr)) + if len(result) > 3: + raise ValidationError("Maximaal drie DNS-servers.") + return result + + +def ssid(value: str) -> str: + val = value or "" + if not val: + raise ValidationError("Vul een netwerknaam (SSID) in.") + if len(val.encode("utf-8")) > 32: + raise ValidationError("Een SSID is maximaal 32 bytes.") + if _CONTROL.search(val): + raise ValidationError("De SSID bevat ongeldige tekens.") + return val + + +def wifi_psk(value: str) -> str: + val = value or "" + if _HEX64.match(val): + return val + if not 8 <= len(val) <= 63: + raise ValidationError("Een wifi-wachtwoord is 8 tot 63 tekens (of 64 hexadecimale tekens).") + if any(not (32 <= ord(ch) <= 126) for ch in val): + raise ValidationError("Het wifi-wachtwoord mag alleen gewone ASCII-tekens bevatten.") + return val + + +def country_code(value: str) -> str: + val = (value or "").strip().upper() + if not _COUNTRY.match(val): + raise ValidationError("Gebruik een landcode van twee letters, bijvoorbeeld NL.") + return val diff --git a/src/voxip_kiosk/web/__init__.py b/src/voxip_kiosk/web/__init__.py new file mode 100644 index 0000000..955f408 --- /dev/null +++ b/src/voxip_kiosk/web/__init__.py @@ -0,0 +1,73 @@ +"""VoxIP Kiosk Manager — Flask application factory.""" + +from __future__ import annotations + +import os +import secrets +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from flask import Flask + +from ..cdp import CDP +from ..config import load_config +from ..helper_client import HelperClient +from ..paths import Paths, load_paths, read_version +from .auth import CredentialStore, LoginThrottle + + +@dataclass +class Services: + paths: Paths + credentials: CredentialStore + throttle: LoginThrottle + helper: Any + cdp: Any + version: str + + +def _secret_key(paths: Paths) -> bytes: + path = paths.secret_key_file + try: + key = path.read_bytes() + if len(key) >= 32: + return key + except FileNotFoundError: + pass + key = secrets.token_bytes(64) + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as fh: + fh.write(key) + return key + + +def create_app(paths: Paths | None = None, *, helper: Any = None, cdp: Any = None) -> Flask: + paths = paths or load_paths() + config = load_config(paths.config_file) + app = Flask(__name__) + app.secret_key = _secret_key(paths) + app.config.update( + SESSION_COOKIE_NAME="voxip_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Strict", + SESSION_COOKIE_SECURE=config["admin_https"], + PERMANENT_SESSION_LIFETIME=timedelta(hours=12), + MAX_CONTENT_LENGTH=64 * 1024, + TEMPLATES_AUTO_RELOAD=False, + ) + credentials = CredentialStore(paths.admin_file, paths.setup_code_file) + credentials.ensure_setup_code() + app.extensions["voxip"] = Services( + paths=paths, + credentials=credentials, + throttle=LoginThrottle(), + helper=helper or HelperClient(paths.helper), + cdp=cdp or CDP(timeout=5), + version=read_version(paths), + ) + + from .views import bp + + app.register_blueprint(bp) + return app diff --git a/src/voxip_kiosk/web/auth.py b/src/voxip_kiosk/web/auth.py new file mode 100644 index 0000000..b91b477 --- /dev/null +++ b/src/voxip_kiosk/web/auth.py @@ -0,0 +1,156 @@ +"""Admin credentials (Argon2id) and login throttling.""" + +from __future__ import annotations + +import json +import secrets +import threading +import time +from collections import deque +from pathlib import Path +from typing import Any + +from argon2 import PasswordHasher, Type +from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError + +from .. import setupcode +from ..config import atomic_write_json + +# 64 MiB / 3 passes / 2 lanes: ~0.5 s on a Raspberry Pi 3, fine for a single admin. +HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16, type=Type.ID) + +MIN_LENGTH = 10 +MAX_LENGTH = 128 +WEAK = {"admin", "administrator", "password", "wachtwoord", "raspberry", "voxip", "kiosk", "welkom", "welcome", "qwerty", "123456"} + + +def password_problem(password: str, confirm: str | None = None) -> str | None: + if confirm is not None and password != confirm: + return "De wachtwoorden komen niet overeen." + if len(password) < MIN_LENGTH: + return f"Gebruik minimaal {MIN_LENGTH} tekens." + if len(password) > MAX_LENGTH: + return f"Gebruik maximaal {MAX_LENGTH} tekens." + remainder = password.lower() + for word in WEAK: + remainder = remainder.replace(word, "") + if len(set(password)) < 4 or len(remainder) < 6 or remainder.isdigit(): + return "Dit wachtwoord is te eenvoudig. Kies een uniek wachtwoord." + return None + + +class CredentialStore: + def __init__(self, admin_file: Path, setup_code_file: Path): + self.admin_file = admin_file + self.setup_code_file = setup_code_file + self._lock = threading.Lock() + + def _load(self) -> dict[str, Any] | None: + try: + data = json.loads(self.admin_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or not str(data.get("password_hash", "")).startswith("$argon2id$"): + return None + return data + + def is_configured(self) -> bool: + return self._load() is not None + + def ensure_setup_code(self) -> str | None: + """Create a setup code while no password exists; remove it afterwards.""" + if self.is_configured(): + self.setup_code_file.unlink(missing_ok=True) + return None + code = setupcode.read(self.setup_code_file) + if not code: + code = setupcode.generate() + setupcode.write(self.setup_code_file, code) + return code + + def check_setup_code(self, candidate: str) -> bool: + return setupcode.matches(self.setup_code_file, candidate) + + def set_password(self, password: str) -> str: + """Store a new password hash; returns the new session epoch (old sessions become invalid).""" + epoch = secrets.token_hex(16) + record = { + "password_hash": HASHER.hash(password), + "session_epoch": epoch, + "updated_at": int(time.time()), + } + with self._lock: + atomic_write_json(self.admin_file, record, mode=0o600) + self.setup_code_file.unlink(missing_ok=True) + return epoch + + def verify(self, password: str) -> bool: + data = self._load() + if data is None or len(password) > MAX_LENGTH: + return False + try: + HASHER.verify(data["password_hash"], password) + except (VerifyMismatchError, VerificationError, InvalidHash): + return False + if HASHER.check_needs_rehash(data["password_hash"]): + data["password_hash"] = HASHER.hash(password) + with self._lock: + atomic_write_json(self.admin_file, data, mode=0o600) + return True + + def session_epoch(self) -> str | None: + data = self._load() + return data.get("session_epoch") if data else None + + +class LoginThrottle: + """Per-IP exponential lockout plus a global brake against distributed guessing.""" + + def __init__(self, free_attempts: int = 5, window: float = 900, max_lock: float = 900, + global_limit: int = 30, global_window: float = 300, clock=time.monotonic): + self.free_attempts = free_attempts + self.window = window + self.max_lock = max_lock + self.global_limit = global_limit + self.global_window = global_window + self.clock = clock + self._lock = threading.Lock() + self._failures: dict[str, deque] = {} + self._locked_until: dict[str, float] = {} + self._global: deque = deque() + + def _prune(self, now: float) -> None: + while self._global and now - self._global[0] > self.global_window: + self._global.popleft() + for ip in list(self._failures): + q = self._failures[ip] + while q and now - q[0] > self.window: + q.popleft() + if not q and self._locked_until.get(ip, 0) <= now: + self._failures.pop(ip, None) + self._locked_until.pop(ip, None) + + def retry_after(self, ip: str) -> int: + with self._lock: + now = self.clock() + self._prune(now) + wait = self._locked_until.get(ip, 0) - now + if len(self._global) >= self.global_limit: + wait = max(wait, self.global_window - (now - self._global[0])) + return max(0, int(wait + 0.999)) + + def failure(self, ip: str) -> None: + with self._lock: + now = self.clock() + self._prune(now) + q = self._failures.setdefault(ip, deque()) + q.append(now) + self._global.append(now) + excess = len(q) - self.free_attempts + if excess >= 0: + self._locked_until[ip] = now + min(self.max_lock, 30 * (2 ** excess)) + + def success(self, ip: str) -> None: + with self._lock: + self._failures.pop(ip, None) + self._locked_until.pop(ip, None) diff --git a/src/voxip_kiosk/web/security.py b/src/voxip_kiosk/web/security.py new file mode 100644 index 0000000..35db2bb --- /dev/null +++ b/src/voxip_kiosk/web/security.py @@ -0,0 +1,90 @@ +"""Request-level protections: client network filter, CSRF, security headers.""" + +from __future__ import annotations + +import hmac +import ipaddress +import secrets +from urllib.parse import urlsplit + +from flask import Request, session + +CSRF_FIELD = "csrf_token" +CSRF_HEADER = "X-CSRF-Token" +_VPN_RANGES = (ipaddress.ip_network("100.64.0.0/10"),) # CGNAT, used by Tailscale and many VPNs + +CSP = "; ".join([ + "default-src 'self'", + "img-src 'self' data: blob:", + "style-src 'self'", + "script-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'none'", + "object-src 'none'", +]) + + +def client_allowed(remote_addr: str | None, allow_public: bool, local_networks: list[str] = ()) -> bool: + """Only local networks may reach the admin interface unless explicitly opened up. + + Local means: private/link-local/loopback ranges, CGNAT (VPN) or one of + the subnets this device itself is on (covers global IPv6 prefixes on + the LAN). + """ + if allow_public: + return True + try: + addr = ipaddress.ip_address((remote_addr or "").split("%", 1)[0]) + except ValueError: + return False + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: + addr = addr.ipv4_mapped + if addr.is_multicast: + return False + if addr.is_loopback or addr.is_link_local or addr.is_private: + return True + if any(addr in net for net in _VPN_RANGES if addr.version == net.version): + return True + for cidr in local_networks: + try: + net = ipaddress.ip_interface(cidr).network + except ValueError: + continue + if net.version == addr.version and addr in net and net.prefixlen >= (16 if net.version == 4 else 48): + return True + return False + + +def csrf_token() -> str: + token = session.get("csrf") + if not token: + token = secrets.token_urlsafe(32) + session["csrf"] = token + return token + + +def csrf_valid(request: Request) -> bool: + expected = session.get("csrf") + supplied = request.headers.get(CSRF_HEADER) or request.form.get(CSRF_FIELD, "") + if not expected or not supplied or not hmac.compare_digest(expected, supplied): + return False + origin = request.headers.get("Origin") + if origin and origin != "null": + return urlsplit(origin).netloc == request.host + return True + + +def apply_headers(response, secure: bool): + response.headers.setdefault("Content-Security-Policy", CSP) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + if response.mimetype in ("text/html", "application/json") or response.mimetype.startswith("image/"): + response.headers["Cache-Control"] = "no-store" + if secure: + response.headers["Strict-Transport-Security"] = "max-age=31536000" + return response diff --git a/src/voxip_kiosk/web/static/app.css b/src/voxip_kiosk/web/static/app.css new file mode 100644 index 0000000..b3167ab --- /dev/null +++ b/src/voxip_kiosk/web/static/app.css @@ -0,0 +1,155 @@ +:root { + --navy: #0b2545; + --navy-2: #13315c; + --accent: #1d6fd8; + --accent-dark: #1559b0; + --bg: #f2f5f9; + --card: #ffffff; + --text: #1b2433; + --muted: #5c6b80; + --border: #dde3eb; + --ok: #1f9d55; + --warning: #d98a00; + --error: #d64545; + --stopped: #8592a3; + --starting: #2f80ed; + --radius: 12px; + --shadow: 0 1px 2px rgba(11, 37, 69, .06), 0 4px 16px rgba(11, 37, 69, .06); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); line-height: 1.5; } +a { color: var(--accent); } +code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; background: #eef2f7; padding: .05em .35em; border-radius: 4px; } +h1 { font-size: 1.6rem; margin: 0 0 .25rem; } +h2 { font-size: 1.1rem; margin: 0 0 .9rem; } +.mt { margin-top: 1.6rem; } +.muted { color: var(--muted); } +.small { font-size: .875rem; } +.break { overflow-wrap: anywhere; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* --- top bar --------------------------------------------------------------- */ +.topbar { background: var(--navy); color: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, .15); position: sticky; top: 0; z-index: 10; } +.topbar-inner { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; align-items: center; gap: 1rem; min-height: 60px; flex-wrap: wrap; } +.brand { display: flex; align-items: center; gap: .6rem; color: #fff; text-decoration: none; font-size: 1.1rem; margin-right: auto; } +.mainnav { display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; } +.mainnav a, .mainnav button { color: #d6e2f3; text-decoration: none; padding: .5rem .75rem; border-radius: 8px; font: inherit; font-size: .95rem; background: none; border: 0; cursor: pointer; } +.mainnav a:hover, .mainnav button:hover { background: var(--navy-2); color: #fff; } +.mainnav a[aria-current="page"] { background: var(--navy-2); color: #fff; box-shadow: inset 0 -3px 0 #4f9cf9; } +.nav-logout { margin: 0; } +.nav-toggle { display: none; background: none; border: 1px solid rgba(255, 255, 255, .3); border-radius: 8px; width: 44px; height: 40px; cursor: pointer; } +.nav-toggle .bars, .nav-toggle .bars::before, .nav-toggle .bars::after { display: block; width: 20px; height: 2px; background: #fff; margin: 0 auto; position: relative; content: ""; } +.nav-toggle .bars::before { position: absolute; top: -6px; } +.nav-toggle .bars::after { position: absolute; top: 6px; } + +@media (max-width: 860px) { + .nav-toggle { display: block; } + .mainnav { display: none; width: 100%; flex-direction: column; align-items: stretch; padding-bottom: .75rem; } + .mainnav.open { display: flex; } + .mainnav a, .mainnav button { padding: .75rem; text-align: left; width: 100%; } +} + +/* --- layout ------------------------------------------------------------------ */ +.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 1.5rem 1rem 2rem; flex: 1; } +.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; } +.page-head p { margin: 0; } +.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-bottom: 1rem; } +.grid-2 { grid-template-columns: repeat(auto-fill, minmax(min(100%, 460px), 1fr)); } +.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; box-shadow: var(--shadow); margin-bottom: 1rem; min-width: 0; } +.grid > .card { margin-bottom: 0; } +.card-narrow { max-width: 460px; margin: 2rem auto; } +.card-pending { border-color: var(--warning); background: #fff8eb; } +.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .5rem; padding: 1rem; color: var(--muted); font-size: .85rem; max-width: 1200px; width: 100%; margin: 0 auto; } + +/* --- key/value lists ---------------------------------------------------------- */ +.kv { display: grid; grid-template-columns: minmax(110px, 38%) 1fr; gap: .55rem 1rem; margin: 0; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; min-width: 0; } +.kv-tight { margin-top: 1rem; font-size: .92rem; } +.note { margin: 1rem 0 0; padding: .6rem .8rem; background: #fff4e5; border-radius: 8px; font-size: .9rem; } + +/* --- status badges ------------------------------------------------------------- */ +.badge { display: inline-flex; align-items: center; gap: .45rem; padding: .2rem .7rem; border-radius: 999px; font-size: .9rem; font-weight: 600; background: #eef2f7; } +.badge .dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--stopped); flex: none; } +.badge-ok { background: #e3f5ea; color: #146c3b; } .badge-ok .dot { background: var(--ok); } +.badge-warning { background: #fff1d6; color: #8a5800; } .badge-warning .dot { background: var(--warning); } +.badge-error { background: #fde6e6; color: #a32c2c; } .badge-error .dot { background: var(--error); } +.badge-starting { background: #e3eefc; color: #1d4f9c; } .badge-starting .dot { background: var(--starting); animation: pulse 1.2s infinite; } +.badge-stopped, .badge-unknown { color: #4a5668; } +@keyframes pulse { 50% { opacity: .35; } } + +.meter { height: 6px; border-radius: 3px; background: #e6ebf2; margin-top: .35rem; overflow: hidden; } +.meter span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .4s; } +.meter span.is-mid { background: var(--warning); } +.meter span.is-high { background: var(--error); } + +/* --- buttons ---------------------------------------------------------------------- */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; font: inherit; font-weight: 600; padding: .6rem 1.1rem; border-radius: 10px; border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background .15s, transform .05s; min-height: 44px; } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: .6; cursor: progress; } +.btn-primary { background: var(--accent); color: #fff; } .btn-primary:hover { background: var(--accent-dark); } +.btn-secondary { background: #fff; color: var(--navy); border-color: var(--border); } .btn-secondary:hover { background: #f1f4f8; } +.btn-warning { background: #f0a020; color: #2b1d00; } .btn-warning:hover { background: #dd9010; } +.btn-danger { background: var(--error); color: #fff; } .btn-danger:hover { background: #b93636; } +.btn-success { background: var(--ok); color: #fff; } .btn-success:hover { background: #17834a; } +.btn-dark { background: var(--navy); color: #fff; } .btn-dark:hover { background: var(--navy-2); } +.btn-big { font-size: 1.05rem; padding: 1rem 1.5rem; min-height: 64px; min-width: 220px; border-radius: 14px; box-shadow: var(--shadow); } +.btn-block { width: 100%; } +.actions-row { display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: 1rem; } +.inline-form { margin: 0; } +@media (max-width: 560px) { + .actions-row .inline-form, .actions-row .btn-big { width: 100%; } +} + +/* --- forms -------------------------------------------------------------------------- */ +.form { display: flex; flex-direction: column; gap: .9rem; } +.form label { display: flex; flex-direction: column; gap: .3rem; font-weight: 600; font-size: .95rem; } +.form small { font-weight: 400; color: var(--muted); } +.form input[type=text], .form input[type=url], .form input[type=password], .form input[type=number], .form select { + font: inherit; font-weight: 400; padding: .6rem .7rem; border: 1px solid #c5cfdb; border-radius: 8px; background: #fff; color: var(--text); width: 100%; min-height: 44px; +} +.form input:focus, .form select:focus { outline: 2px solid #8ab8f5; outline-offset: 1px; border-color: var(--accent); } +.form label.check { flex-direction: row; align-items: center; gap: .55rem; font-weight: 400; } +.form label.check input { width: 1.15rem; height: 1.15rem; } +.form .btn { align-self: flex-start; } +.form .btn-block { align-self: stretch; } +.form-inline { flex-direction: row; flex-wrap: wrap; align-items: flex-end; } +.form-inline label { min-width: 150px; } +.input-short { max-width: 6rem; text-transform: uppercase; } +.input-code { text-transform: uppercase; letter-spacing: .15em; font-family: ui-monospace, Menlo, monospace !important; } +.input-row { display: flex; gap: .5rem; } +.input-row input { flex: 1; } +.radio-row { border: 0; padding: 0; margin: 0; display: flex; gap: 1.5rem; flex-wrap: wrap; } +.radio-row legend { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; padding: 0; } +.static-fields { display: flex; flex-direction: column; gap: .9rem; } +.static-fields[hidden] { display: none; } +.scan-results { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; max-height: 240px; overflow: auto; } +.scan-results li button { width: 100%; display: flex; justify-content: space-between; gap: 1rem; padding: .55rem .75rem; background: none; border: 0; border-bottom: 1px solid var(--border); font: inherit; cursor: pointer; text-align: left; } +.scan-results li button:hover { background: #f1f4f8; } +.plain { margin: 0; padding-left: 1.2rem; } +.plain li { margin-bottom: .4rem; } + +/* --- alerts --------------------------------------------------------------------------- */ +.alert { padding: .8rem 1rem; border-radius: 10px; margin-bottom: 1rem; border: 1px solid; } +.alert-success { background: #e3f5ea; border-color: #b6e2c6; color: #145c33; } +.alert-error { background: #fde6e6; border-color: #f4bcbc; color: #8f2424; } +.alert-warning { background: #fff1d6; border-color: #f3d39b; color: #7a4e00; } +.alert-info { background: #e3eefc; border-color: #bcd4f7; color: #1d4f9c; } + +/* --- logs & screenshot ----------------------------------------------------------------- */ +.log { background: #0f1b2d; color: #d7e3f4; padding: 1rem; border-radius: 10px; font-size: .8rem; line-height: 1.45; overflow: auto; max-height: 65vh; white-space: pre-wrap; word-break: break-word; margin: 1rem 0 0; } +.log-short { max-height: 280px; margin-top: 0; } +.screenshot { background: #0f1b2d; border-radius: 10px; min-height: 160px; display: flex; align-items: center; justify-content: center; margin-bottom: .8rem; overflow: hidden; } +.screenshot p { color: #b9c7da; padding: 1rem; text-align: center; margin: 0; } +.screenshot img { width: 100%; height: auto; display: block; } + +/* --- dialog ------------------------------------------------------------------------------ */ +.dialog { border: 0; border-radius: var(--radius); padding: 1.5rem; max-width: min(440px, calc(100vw - 2rem)); box-shadow: 0 20px 60px rgba(0, 0, 0, .3); } +.dialog::backdrop { background: rgba(11, 37, 69, .55); } +.dialog form { margin: 0; } +.dialog-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.25rem; flex-wrap: wrap; } diff --git a/src/voxip_kiosk/web/static/app.js b/src/voxip_kiosk/web/static/app.js new file mode 100644 index 0000000..5da7fff --- /dev/null +++ b/src/voxip_kiosk/web/static/app.js @@ -0,0 +1,206 @@ +/* VoxIP Kiosk Manager — progressive enhancements. Every page works without JS. */ +(function () { + "use strict"; + + const csrf = () => document.querySelector('meta[name="csrf-token"]').content; + const $ = (sel, root = document) => root.querySelector(sel); + const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + + // Mobile navigation + const toggle = $("[data-nav-toggle]"); + if (toggle) { + toggle.addEventListener("click", () => { + const nav = $("#mainnav"); + const open = nav.classList.toggle("open"); + toggle.setAttribute("aria-expanded", String(open)); + }); + } + + // Confirmation dialog for dangerous actions (forms with data-confirm) + const dialog = $("#confirm-dialog"); + $$("form[data-confirm]").forEach((form) => { + form.addEventListener("submit", (event) => { + if (form.dataset.confirmed === "1" || !dialog || typeof dialog.showModal !== "function") return; + event.preventDefault(); + $("[data-confirm-title]", dialog).textContent = form.dataset.confirmTitle || "Weet je het zeker?"; + $("[data-confirm-text]", dialog).textContent = form.dataset.confirm; + dialog.returnValue = ""; + dialog.showModal(); + dialog.addEventListener("close", function onClose() { + dialog.removeEventListener("close", onClose); + if (dialog.returnValue === "ok") { + form.dataset.confirmed = "1"; + form.requestSubmit ? form.requestSubmit() : form.submit(); + } + }); + }); + }); + + // Disable buttons while a form submits, to prevent double actions + $$("form").forEach((form) => { + form.addEventListener("submit", (event) => { + if (event.defaultPrevented || form.method.toLowerCase() !== "post") return; + setTimeout(() => $$("button[type=submit], button:not([type])", form).forEach((b) => (b.disabled = true)), 0); + }); + }); + + // Meters (width set here because the CSP forbids inline styles) + function setMeter(el, pct) { + const value = Math.max(0, Math.min(100, Number(pct) || 0)); + el.style.width = value + "%"; + el.classList.toggle("is-high", value >= 90); + el.classList.toggle("is-mid", value >= 75 && value < 90); + } + $$("[data-meter]").forEach((el) => setMeter(el, el.dataset.pct)); + + // Local time for epoch timestamps + $$("time[data-epoch]").forEach((el) => { + const d = new Date(Number(el.dataset.epoch) * 1000); + el.dateTime = d.toISOString(); + el.textContent = d.toLocaleString("nl-NL", { dateStyle: "short", timeStyle: "medium" }); + }); + + // Live dashboard + const live = $("[data-live-status]"); + if (live) { + const get = (obj, path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), obj); + const refresh = async () => { + try { + const res = await fetch(live.dataset.liveStatus, { headers: { Accept: "application/json" }, cache: "no-store" }); + if (res.status === 401) return location.reload(); + if (!res.ok) return; + const data = await res.json(); + $$("[data-field]").forEach((el) => { + const value = get(data, el.dataset.field); + if (value !== undefined && value !== null) el.textContent = value; + }); + $$("[data-meter]").forEach((el) => setMeter(el, data[el.dataset.meter])); + $$("[data-badge]").forEach((el) => { + const key = el.dataset.badge; + let status, label; + if (key === "temperature") { status = data.temperature_status; label = data.temperature; } + else if (data[key]) { status = data[key].status; label = data[key].label; } + if (!status) return; + el.className = "badge badge-" + status; + $("[data-badge-label]", el).textContent = label; + }); + } catch (e) { /* device rebooting or offline: try again next round */ } + }; + setInterval(refresh, 5000); + } + + // Screenshot + const shotBtn = $("[data-screenshot-btn]"); + if (shotBtn) { + const box = $("[data-screenshot]"); + const img = $("img", box); + const msg = $("[data-screenshot-msg]", box); + shotBtn.addEventListener("click", async () => { + shotBtn.disabled = true; + msg.textContent = "Screenshot maken…"; + msg.hidden = false; + try { + const res = await fetch(box.dataset.screenshot + "?t=" + Date.now(), { cache: "no-store" }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + if (img.src) URL.revokeObjectURL(img.src); + img.src = URL.createObjectURL(blob); + img.hidden = false; + msg.hidden = true; + } catch (e) { + img.hidden = true; + msg.textContent = e.message || "Screenshot niet beschikbaar."; + } finally { + shotBtn.disabled = false; + } + }); + } + + // Static IP fields only when "Statisch" is selected + $$("form[data-ipform]").forEach((form) => { + const fields = $("[data-static-fields]", form); + const update = () => { + const method = $('input[name="method"]:checked', form); + fields.hidden = !method || method.value !== "static"; + }; + $$('input[name="method"]', form).forEach((r) => r.addEventListener("change", update)); + update(); + }); + + // "Scherm altijd aan" hides the timeout field + $$("[data-toggle-target]").forEach((box) => { + const target = $(box.dataset.toggleTarget); + const update = () => { if (target) target.hidden = box.checked; }; + box.addEventListener("change", update); + update(); + }); + + // Wifi scan + const scanBtn = $("[data-wifi-scan]"); + if (scanBtn) { + const form = scanBtn.closest("form"); + const list = $("[data-scan-results]", form); + const datalist = $("#wifi-networks"); + scanBtn.addEventListener("click", async () => { + scanBtn.disabled = true; + scanBtn.textContent = "Scannen…"; + list.hidden = false; + list.replaceChildren(Object.assign(document.createElement("li"), { textContent: "Netwerken zoeken…", className: "muted" })); + try { + const res = await fetch(scanBtn.dataset.wifiScan, { method: "POST", headers: { "X-CSRF-Token": csrf(), Accept: "application/json" } }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Scannen mislukt"); + list.replaceChildren(); + datalist.replaceChildren(); + if (!data.networks.length) list.append(Object.assign(document.createElement("li"), { textContent: "Geen netwerken gevonden.", className: "muted" })); + data.networks.forEach((n) => { + const li = document.createElement("li"); + const b = document.createElement("button"); + b.type = "button"; + const name = document.createElement("span"); + name.textContent = n.ssid + (n.in_use ? " (verbonden)" : ""); + const meta = document.createElement("span"); + meta.className = "muted"; + meta.textContent = n.signal + "% · " + (n.security === "open" ? "open" : n.security); + b.append(name, meta); + b.addEventListener("click", () => { + $('input[name="ssid"]', form).value = n.ssid; + $('input[name="open"]', form).checked = n.security === "open"; + list.hidden = true; + $('input[name="psk"]', form).focus(); + }); + li.append(b); + list.append(li); + datalist.append(Object.assign(document.createElement("option"), { value: n.ssid })); + }); + } catch (e) { + list.replaceChildren(Object.assign(document.createElement("li"), { textContent: e.message, className: "muted" })); + } finally { + scanBtn.disabled = false; + scanBtn.textContent = "Scannen"; + } + }); + } + + // Pending network change countdown + const pending = $("[data-pending]"); + if (pending) { + let remaining = Number(pending.dataset.remaining) || 0; + const counter = $("[data-countdown]", pending); + const tick = setInterval(() => { + remaining = Math.max(0, remaining - 1); + counter.textContent = remaining; + if (remaining === 0) { + clearInterval(tick); + setTimeout(() => location.reload(), 5000); + } + }, 1000); + } + + // Redirect after the admin interface moved + const moved = $("[data-redirect]"); + if (moved) setTimeout(() => (location.href = moved.dataset.redirect), Number(moved.dataset.redirectAfter) * 1000); + + // Scroll log views to the newest line + $$("[data-scroll-bottom]").forEach((el) => (el.scrollTop = el.scrollHeight)); +})(); diff --git a/src/voxip_kiosk/web/static/logo.svg b/src/voxip_kiosk/web/static/logo.svg new file mode 100644 index 0000000..7ee42a8 --- /dev/null +++ b/src/voxip_kiosk/web/static/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/voxip_kiosk/web/templates/_ipv4_fields.html b/src/voxip_kiosk/web/templates/_ipv4_fields.html new file mode 100644 index 0000000..18b6e77 --- /dev/null +++ b/src/voxip_kiosk/web/templates/_ipv4_fields.html @@ -0,0 +1,17 @@ +
+ IPv4-adres + + +
+
+ + +
+ diff --git a/src/voxip_kiosk/web/templates/_macros.html b/src/voxip_kiosk/web/templates/_macros.html new file mode 100644 index 0000000..8b9a5af --- /dev/null +++ b/src/voxip_kiosk/web/templates/_macros.html @@ -0,0 +1,19 @@ +{% macro badge(status, label, field=None) -%} +{{ label }} +{%- endmacro %} + +{% macro action(url, label, cls="btn-primary", confirm=None, confirm_title=None, big=False) -%} +
+ + +
+{%- endmacro %} + +{% macro csrf() -%} + +{%- endmacro %} + +{# Width is applied by app.js: the CSP does not allow inline style attributes. #} +{% macro meter(percent, field) -%} + +{%- endmacro %} diff --git a/src/voxip_kiosk/web/templates/base.html b/src/voxip_kiosk/web/templates/base.html new file mode 100644 index 0000000..7ed1757 --- /dev/null +++ b/src/voxip_kiosk/web/templates/base.html @@ -0,0 +1,64 @@ + + + + + + + {% block title %}{{ title or "Beheer" }}{% endblock %} · VoxIP Kiosk Manager + + + + + +
+
+ + + VoxIP Kiosk Manager + + {% if logged_in %} + + + {% endif %} +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+ {{ device_name }} · {{ hostname }} + VoxIP Kiosk OS {{ version }} +
+ + +
+

Weet je het zeker?

+

+
+ + +
+
+
+ + diff --git a/src/voxip_kiosk/web/templates/dashboard.html b/src/voxip_kiosk/web/templates/dashboard.html new file mode 100644 index 0000000..232a83f --- /dev/null +++ b/src/voxip_kiosk/web/templates/dashboard.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% from "_macros.html" import badge, action, meter with context %} +{% set title = "Dashboard" %} +{% block content %} +
+

Dashboard

+

Ververst automatisch elke 5 seconden.

+
+ +
+ {{ action(url_for('ui.kiosk_reload'), "Pagina herladen", "btn-primary", big=True) }} + {{ action(url_for('ui.kiosk_restart'), "Chromium herstarten", "btn-warning", confirm="Chromium wordt afgesloten en opnieuw gestart. Het scherm is enkele seconden zwart.", big=True) }} + {{ action(url_for('ui.system_reboot'), "Raspberry Pi herstarten", "btn-danger", confirm="De Raspberry Pi herstart. Het scherm en de beheerinterface zijn ongeveer een minuut niet beschikbaar.", big=True) }} +
+ +
+
+

Kiosk

+
+
Chromium
{{ badge(s.browser.status, s.browser.label, "browser") }}
+
Kiosk-URL
{{ s.url }}
+
Verbinding
{{ badge(s.network.status, s.network.label, "network") }}
+
Herstarts
{{ s.browser.restarts }}
+
+ {% if s.last_error %} +

Laatste melding: {{ s.last_error.message }}

+ {% endif %} +
+ +
+

Systeem

+
+
Uptime
{{ s.uptime }}
+
CPU-belasting
{{ s.cpu }} (load {{ s.load }})
+
Temperatuur
{{ badge(s.temperature_status, s.temperature, "temperature") }}
+ {% if s.power %}
Voeding
{{ badge(s.power.status, s.power.label, "power") }}
{% endif %} +
+
+ +
+

Geheugen & opslag

+
+
RAM
{{ s.memory }}{{ meter(s.memory_percent, "memory_percent") }}
+
SD-kaart
{{ s.disk }}{{ meter(s.disk_percent, "disk_percent") }}
+
+
+ +
+

Netwerk

+
+
Ethernet
{{ s.eth0 }}
+
Wifi
{{ s.wlan0 }}
+
Hostname
{{ hostname }}.local
+
+
+ +
+

Over dit apparaat

+
+
Naam
{{ device_name }}
+
Versie
VoxIP Kiosk OS {{ s.version }}
+
Besturingssysteem
{{ s.os }}
+
Hardware
{{ s.model }}
+
+
+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/error.html b/src/voxip_kiosk/web/templates/error.html new file mode 100644 index 0000000..31c521c --- /dev/null +++ b/src/voxip_kiosk/web/templates/error.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} +
+

{{ title }}

+

{{ message }}

+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/kiosk.html b/src/voxip_kiosk/web/templates/kiosk.html new file mode 100644 index 0000000..f9b2f7e --- /dev/null +++ b/src/voxip_kiosk/web/templates/kiosk.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% from "_macros.html" import badge, action, csrf with context %} +{% set title = "Kiosk" %} +{% block content %} +
+

Kioskbeheer

+ {{ badge(s.browser.status, s.browser.label) }} +
+ +
+ {{ action(url_for('ui.kiosk_reload'), "Pagina herladen", "btn-primary", big=True) }} + {{ action(url_for('ui.kiosk_restart'), "Chromium herstarten", "btn-warning", confirm="Chromium wordt afgesloten en opnieuw gestart.", big=True) }} +
+ +
+
+

Instellingen

+
+ {{ csrf() }} + + + + + + +
+
+ +
+

Huidig scherm

+
+

Klik op ‘Screenshot maken’ om te zien wat de kiosk nu toont.

+ +
+ +
+
Ingestelde URL
{{ masked_url }}
+
Getoonde pagina
{{ agent.page_url or "–" }}
+
Laatste controle
{% if agent.checked_at %} — {% if agent.target_reachable %}bereikbaar{% else %}{{ agent.reason or "niet bereikbaar" }}{% endif %}{% else %}–{% endif %}
+ {% if agent.offline_since %}
Offline sinds
{% endif %} +
+
+
+ +
+

Laatste meldingen

+ {% if errors %} +
{% for line in errors %}{{ line }}
+{% endfor %}
+ {% else %} +

Geen waarschuwingen of fouten.

+ {% endif %} +

Alle kiosklogs bekijken →

+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/login.html b/src/voxip_kiosk/web/templates/login.html new file mode 100644 index 0000000..594c356 --- /dev/null +++ b/src/voxip_kiosk/web/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% set title = "Inloggen" %} +{% block content %} +
+

Inloggen

+

{{ device_name }} · {{ hostname }}

+ {% if error %}{% endif %} +
+ + + +
+

Wachtwoord vergeten? Log via SSH in en voer sudo voxip-kiosk-ctl reset-password uit.

+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/logs.html b/src/voxip_kiosk/web/templates/logs.html new file mode 100644 index 0000000..3fd26a4 --- /dev/null +++ b/src/voxip_kiosk/web/templates/logs.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% set title = "Logboek" %} +{% block content %} +

Logboek

+
+
+ + + + +
+
{{ text }}
+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/moved.html b/src/voxip_kiosk/web/templates/moved.html new file mode 100644 index 0000000..81dd799 --- /dev/null +++ b/src/voxip_kiosk/web/templates/moved.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% set title = "Beheerinterface herstart" %} +{% block content %} +
+

Beheerinterface herstart

+

De beheerinterface is straks bereikbaar op:

+

{{ target }}

+

Je wordt over enkele seconden automatisch doorgestuurd. Bij HTTPS met een zelfondertekend + certificaat vraagt je browser eenmalig om bevestiging.

+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/network.html b/src/voxip_kiosk/web/templates/network.html new file mode 100644 index 0000000..825e61b --- /dev/null +++ b/src/voxip_kiosk/web/templates/network.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% from "_macros.html" import badge, csrf with context %} +{% set title = "Netwerk" %} +{% block content %} +

Netwerkbeheer

+ +{% if pending %} +
+

Netwerkwijziging bevestigen

+

De nieuwe instellingen voor {{ 'ethernet' if pending.interface == 'eth0' else 'wifi' }} + {% if pending.method == 'static' %}(statisch adres {{ pending.address }}){% else %}(DHCP){% endif %} zijn actief. + Werkt de beheerinterface nog? Bevestig dan. Zonder bevestiging worden over + {{ pending.remaining }} seconden automatisch de vorige instellingen teruggezet.

+ {% if pending.method == 'static' %} +

Is het adres veranderd? Open dan {{ admin_scheme }}://{{ pending.address.split('/')[0] }}:{{ admin_port }}/network, log in en bevestig daar.

+ {% endif %} +
+
{{ csrf() }}
+
{{ csrf() }}
+
+
+{% endif %} + +{% if not net.available %} +
NetworkManager (nmcli) is niet beschikbaar op dit systeem.
+{% endif %} + +
+ {% for iface in ['eth0', 'wlan0'] %} + {% set d = net.devices[iface] %} +
+

{{ 'Ethernet' if iface == 'eth0' else 'Wifi' }} ({{ iface }})

+
+
Status
{{ badge('ok' if d.connected else ('starting' if d.state == 'connecting' else 'stopped'), d.state_label) }}
+ {% if d.connection %}
Verbinding
{{ d.connection }}
{% endif %} +
IP-adres
{{ d.addresses|join(', ') or '–' }}
+
Gateway
{{ d.gateway or '–' }}
+
DNS
{{ d.dns|join(', ') or '–' }}
+ {% if d.hwaddr %}
MAC
{{ d.hwaddr }}
{% endif %} +
+
+ {% endfor %} +
+ +
+
+

Ethernet instellen

+ {% set p = net.devices.eth0.profile %} +
+ {{ csrf() }} + + {% include "_ipv4_fields.html" %} + +
+
+ +
+

Wifi instellen

+ {% set p = net.devices.wlan0.profile %} +
+ {{ csrf() }} + + + + + + + {% include "_ipv4_fields.html" %} + +
+ +

Wifi-land

+
+ {{ csrf() }} + + +
+
+
+ +
+

Herstel bij een onbereikbare kiosk

+
    +
  • Iedere wijziging wordt na {{ rollback_seconds // 60 }} minuten automatisch teruggedraaid als je hem niet bevestigt, ook als de stroom tussendoor uitvalt.
  • +
  • Noodherstel: zet een leeg bestand voxip-network-reset op de bootpartitie van de SD-kaart en start de Pi. Alle VoxIP-netwerkprofielen worden dan verwijderd (terug naar DHCP).
  • +
+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/password.html b/src/voxip_kiosk/web/templates/password.html new file mode 100644 index 0000000..ae07cdc --- /dev/null +++ b/src/voxip_kiosk/web/templates/password.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% set title = "Wachtwoord wijzigen" %} +{% block content %} +
+

Wachtwoord wijzigen

+
+ + + + + +
+

Na het wijzigen worden alle andere sessies uitgelogd.

+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/setup.html b/src/voxip_kiosk/web/templates/setup.html new file mode 100644 index 0000000..bb6dd17 --- /dev/null +++ b/src/voxip_kiosk/web/templates/setup.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% set title = "Eerste configuratie" %} +{% block content %} +
+

Welkom bij VoxIP Kiosk Manager

+

Stel eerst een uniek beheerderswachtwoord in. Je hebt daarvoor de setupcode nodig die op het + scherm van de kiosk staat (of via SSH: sudo voxip-kiosk-ctl setup-code).

+ {% if error %}{% endif %} +
+ + + + + +
+
+{% endblock %} diff --git a/src/voxip_kiosk/web/templates/system.html b/src/voxip_kiosk/web/templates/system.html new file mode 100644 index 0000000..c582e25 --- /dev/null +++ b/src/voxip_kiosk/web/templates/system.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% from "_macros.html" import action, csrf with context %} +{% set title = "Systeem" %} +{% block content %} +

Systeembeheer

+ +
+ {{ action(url_for('ui.kiosk_restart'), "Chromium herstarten", "btn-warning", confirm="Chromium wordt afgesloten en opnieuw gestart.", big=True) }} + {{ action(url_for('ui.system_reboot'), "Raspberry Pi herstarten", "btn-danger", confirm="De Raspberry Pi herstart. Het scherm en de beheerinterface zijn ongeveer een minuut niet beschikbaar.", big=True) }} + {{ action(url_for('ui.system_poweroff'), "Veilig uitschakelen", "btn-dark", confirm="De Raspberry Pi wordt uitgeschakeld. Daarna kan hij alleen weer aan door de stroom los en weer vast te maken.", confirm_title="Raspberry Pi uitschakelen", big=True) }} +
+ +
+
+

Scherm

+
+ {{ csrf() }} + + + + + + + +
+
+ +
+

Hostname

+
+ {{ csrf() }} + + +
+ +

Beheerinterface

+
+ {{ csrf() }} + + + + +
+
+
+ +
+

Systeeminformatie

+
+
Versie
VoxIP Kiosk OS {{ s.version }}
+
Besturingssysteem
{{ s.os }}
+
Hardware
{{ s.model }}
+
Uptime
{{ s.uptime }}
+
+

Systeemlogs bekijken →

+
+{% endblock %} diff --git a/src/voxip_kiosk/web/views.py b/src/voxip_kiosk/web/views.py new file mode 100644 index 0000000..3d66449 --- /dev/null +++ b/src/voxip_kiosk/web/views.py @@ -0,0 +1,551 @@ +"""Routes of the VoxIP Kiosk Manager.""" + +from __future__ import annotations + +import json +import logging +import socket +import time +from typing import Any + +from flask import (Blueprint, Response, abort, current_app, flash, g, jsonify, redirect, render_template, request, + session, url_for) + +from .. import netconfig, netinfo, services, sysinfo +from ..agent import read_state +from ..cdp import CDPError +from ..config import ConfigError, load_config, save_config +from ..helper_client import HelperError +from ..validation import ValidationError, mask_url +from . import security +from .auth import password_problem + +bp = Blueprint("ui", __name__) +audit_log = logging.getLogger("voxip-kiosk-audit") + +IDLE_TIMEOUT = 3600 +PUBLIC_ENDPOINTS = {"ui.login", "ui.setup", "static"} + + +def svc(): + return current_app.extensions["voxip"] + + +def cfg() -> dict[str, Any]: + if "cfg" not in g: + g.cfg = load_config(svc().paths.config_file) + return g.cfg + + +def client_ip() -> str: + return request.remote_addr or "?" + + +def audit(event: str, **details: Any) -> None: + record = {"time": int(time.time()), "event": event, "ip": client_ip(), **details} + audit_log.info(json.dumps(record, ensure_ascii=False)) + try: + with open(svc().paths.audit_log, "a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except OSError: + pass + + +def wants_json() -> bool: + return request.path.startswith("/api/") or request.headers.get("Accept", "").startswith("application/json") + + +_local_nets_cache: tuple[float, list[str]] = (0.0, []) + + +def local_networks() -> list[str]: + global _local_nets_cache + now = time.monotonic() + if now - _local_nets_cache[0] > 60: + nets = [a for addrs in sysinfo.ip_addresses().values() for a in addrs] + _local_nets_cache = (now, nets) + return _local_nets_cache[1] + + +# -- request pipeline ---------------------------------------------------------- + +@bp.before_app_request +def guard(): + if not security.client_allowed(request.remote_addr, cfg()["admin_allow_public"], local_networks()): + abort(403) + if request.endpoint == "static": + return None + creds = svc().credentials + if not creds.is_configured(): + if request.endpoint != "ui.setup": + return redirect(url_for("ui.setup")) + elif request.endpoint == "ui.setup": + return redirect(url_for("ui.dashboard")) + + if session.get("auth"): + expired = time.time() - session.get("seen", 0) > IDLE_TIMEOUT + if expired or session.get("epoch") != creds.session_epoch(): + session.clear() + flash("Je sessie is verlopen. Log opnieuw in.", "info") + if request.endpoint not in PUBLIC_ENDPOINTS and not session.get("auth"): + if wants_json(): + return jsonify(error="Niet ingelogd"), 401 + return redirect(url_for("ui.login")) + if session.get("auth"): + session["seen"] = int(time.time()) + + if request.method == "POST" and not security.csrf_valid(request): + audit("csrf_rejected", path=request.path) + if wants_json(): + return jsonify(error="Beveiligingstoken ongeldig. Herlaad de pagina."), 400 + flash("Beveiligingstoken ongeldig of verlopen. Probeer het opnieuw.", "error") + return redirect(request.path if request.endpoint in PUBLIC_ENDPOINTS else url_for("ui.dashboard")), 303 + return None + + +@bp.after_app_request +def headers(response: Response): + return security.apply_headers(response, secure=current_app.config["SESSION_COOKIE_SECURE"]) + + +@bp.app_context_processor +def template_globals(): + return { + "csrf_token": security.csrf_token, + "version": svc().version, + "device_name": cfg()["device_name"], + "hostname": socket.gethostname().split(".")[0], + "logged_in": bool(session.get("auth")), + "admin_port": cfg()["admin_port"], + "admin_scheme": "https" if cfg()["admin_https"] else "http", + } + + +@bp.app_errorhandler(403) +def forbidden(_): + return render_template("error.html", title="Geen toegang", + message="De beheerinterface is alleen bereikbaar vanaf het lokale netwerk of via VPN."), 403 + + +@bp.app_errorhandler(404) +def not_found(_): + return render_template("error.html", title="Niet gevonden", message="Deze pagina bestaat niet."), 404 + + +@bp.app_errorhandler(413) +def too_large(_): + return render_template("error.html", title="Te groot", message="Het verzoek is te groot."), 413 + + +# -- setup & login -------------------------------------------------------------- + +@bp.route("/setup", methods=["GET", "POST"]) +def setup(): + creds = svc().credentials + error = None + if request.method == "POST": + ip = client_ip() + wait = svc().throttle.retry_after(ip) + password = request.form.get("password", "") + if wait: + error = f"Te veel pogingen. Probeer het over {wait} seconden opnieuw." + elif not creds.check_setup_code(request.form.get("setup_code", "")): + svc().throttle.failure(ip) + audit("setup_code_rejected") + error = "De setupcode klopt niet. Je vindt de code op het scherm van de kiosk." + else: + error = password_problem(password, request.form.get("password_confirm", "")) + if error is None: + epoch = creds.set_password(password) + svc().throttle.success(ip) + session.clear() + session.update(auth=True, epoch=epoch, seen=int(time.time())) + session.permanent = True + audit("setup_completed") + flash("Het beheerderswachtwoord is ingesteld. Welkom!", "success") + return redirect(url_for("ui.dashboard")) + return render_template("setup.html", error=error) + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if session.get("auth"): + return redirect(url_for("ui.dashboard")) + error = None + if request.method == "POST": + ip = client_ip() + wait = svc().throttle.retry_after(ip) + if wait: + error = f"Te veel mislukte pogingen. Probeer het over {wait} seconden opnieuw." + elif svc().credentials.verify(request.form.get("password", "")): + svc().throttle.success(ip) + session.clear() + session.update(auth=True, epoch=svc().credentials.session_epoch(), seen=int(time.time())) + session.permanent = True + audit("login") + return redirect(url_for("ui.dashboard")) + else: + svc().throttle.failure(ip) + audit("login_failed") + error = "Onjuist wachtwoord." + return render_template("login.html", error=error) + + +@bp.post("/logout") +def logout(): + audit("logout") + session.clear() + flash("Je bent uitgelogd.", "info") + return redirect(url_for("ui.login")) + + +@bp.route("/account/password", methods=["GET", "POST"]) +def change_password(): + if request.method == "POST": + creds = svc().credentials + if not creds.verify(request.form.get("current_password", "")): + audit("password_change_failed") + flash("Het huidige wachtwoord klopt niet.", "error") + else: + password = request.form.get("password", "") + problem = password_problem(password, request.form.get("password_confirm", "")) + if problem: + flash(problem, "error") + else: + epoch = creds.set_password(password) + session["epoch"] = epoch + audit("password_changed") + flash("Wachtwoord gewijzigd. Andere sessies zijn uitgelogd.", "success") + return redirect(url_for("ui.dashboard")) + return render_template("password.html", active="account") + + +# -- dashboard ------------------------------------------------------------------- + +def browser_state(units: dict[str, Any], agent: dict[str, Any]) -> tuple[str, str]: + unit = units.get("browser", {}) + if unit.get("status") != "ok": + return unit.get("status", "unknown"), unit.get("label", "Onbekend") + if agent.get("browser") == "unreachable": + return "starting", "Bezig met starten" + page = agent.get("page") + if page == "offline": + return "warning", "Actief — offlinepagina" + if page == "setup": + return "warning", "Actief — wacht op initiële configuratie" + if page in ("start", "blank", "error"): + return "starting", "Pagina wordt geladen" + return "ok", "Actief" + + +def collect_status() -> dict[str, Any]: + conf = cfg() + paths = svc().paths + agent = read_state(paths.agent_state_file) + units = services.summary() + b_status, b_label = browser_state(units, agent) + mem = sysinfo.memory() + disk = sysinfo.disk("/") + temp = sysinfo.temperature_c() + load = sysinfo.load_average() + cpu = sysinfo.cpu_percent() + throttle = sysinfo.throttled() + reachable = agent.get("target_reachable") + if reachable is None: + net_status, net_label = "unknown", "Nog niet gecontroleerd" + elif reachable: + net_status, net_label = "ok", "Online — kiosk-URL bereikbaar" + else: + net_status, net_label = "error", f"Offline — {agent.get('reason') or 'niet bereikbaar'}" + addresses = sysinfo.ip_addresses() + return { + "browser": {"status": b_status, "label": b_label, "restarts": units.get("browser", {}).get("restarts", "0")}, + "agent": units.get("agent", {}), + "url": mask_url(conf["url"]), + "refresh_interval": conf["refresh_interval"], + "uptime": sysinfo.format_duration(sysinfo.uptime_seconds()), + "cpu": f"{cpu:.0f}%".replace(".", ",") if cpu is not None else "–", + "cpu_percent": cpu or 0, + "load": " / ".join(f"{x:.2f}".replace(".", ",") for x in load) if load else "–", + "temperature": f"{temp:.1f} °C".replace(".", ",") if temp is not None else "–", + "temperature_status": "error" if temp and temp >= 80 else "warning" if temp and temp >= 70 else "ok", + "memory": f"{sysinfo.format_bytes(mem['used'])} van {sysinfo.format_bytes(mem['total'])}" if mem else "–", + "memory_percent": mem["percent"] if mem else 0, + "disk": f"{sysinfo.format_bytes(disk['used'])} van {sysinfo.format_bytes(disk['total'])}" if disk else "–", + "disk_percent": disk["percent"] if disk else 0, + "eth0": ", ".join(addresses.get("eth0", [])) or "Niet verbonden", + "wlan0": ", ".join(addresses.get("wlan0", [])) or "Niet verbonden", + "network": {"status": net_status, "label": net_label}, + "power": {"status": "error" if throttle and throttle["now"] else "warning" if throttle and throttle["ever"] else "ok", + "label": ", ".join(throttle["flags"]) if throttle and throttle["flags"] else "In orde"} if throttle else None, + "last_error": agent.get("last_error"), + "version": svc().version, + "os": sysinfo.os_release(), + "model": sysinfo.model(), + } + + +@bp.get("/") +def dashboard(): + return render_template("dashboard.html", active="dashboard", s=collect_status()) + + +@bp.get("/api/status") +def api_status(): + return jsonify(collect_status()) + + +# -- kiosk ----------------------------------------------------------------------- + +@bp.get("/kiosk") +def kiosk(): + agent = read_state(svc().paths.agent_state_file) + return render_template("kiosk.html", active="kiosk", conf=cfg(), agent=agent, + errors=services.recent_errors(25), masked_url=mask_url(cfg()["url"]), + s={"browser": dict(zip(("status", "label"), browser_state(services.summary(), agent)))}) + + +def _form_bool(name: str) -> bool: + return request.form.get(name) in ("1", "on", "true", "yes") + + +def _save(updates: dict[str, Any]) -> dict[str, Any] | None: + try: + return save_config(svc().paths.config_file, updates) + except ConfigError as exc: + for message in exc.errors.values(): + flash(message, "error") + except OSError as exc: + flash(f"Opslaan mislukt: {exc}", "error") + return None + + +@bp.post("/kiosk/settings") +def kiosk_settings(): + updates = { + "url": request.form.get("url", ""), + "device_name": request.form.get("device_name", ""), + "refresh_interval": request.form.get("refresh_interval", "0"), + "browser_restart": _form_bool("browser_restart"), + "browser_profile": request.form.get("browser_profile", "persistent"), + } + old = cfg() + new = _save(updates) + if new is not None: + changed = sorted(k for k in updates if old.get(k) != new.get(k)) + audit("kiosk_settings", changed=changed, url=mask_url(new["url"])) + message = "Instellingen opgeslagen." + if "url" in changed: + message += " De kiosk opent de nieuwe URL binnen enkele seconden." + if "browser_profile" in changed: + message += " Het profieltype geldt na het herstarten van Chromium." + flash(message, "success") + return redirect(url_for("ui.kiosk")) + + +def _action_response(ok: bool, message: str, back: str): + if wants_json(): + return jsonify(ok=ok, message=message), (200 if ok else 500) + flash(message, "success" if ok else "error") + return redirect(back) + + +@bp.post("/kiosk/reload") +def kiosk_reload(): + try: + svc().cdp.reload() + audit("page_reload") + return _action_response(True, "De pagina wordt herladen.", request.referrer or url_for("ui.dashboard")) + except CDPError as exc: + return _action_response(False, f"Herladen mislukt: {exc}", request.referrer or url_for("ui.dashboard")) + + +def _helper_action(event: str, verb: str, *args: str, back: str): + try: + result = svc().helper.call(verb, *args) + audit(event, **({"args": list(args)} if args else {})) + return _action_response(True, result.get("message", "Uitgevoerd."), back) + except HelperError as exc: + audit(event + "_failed", error=str(exc)) + return _action_response(False, str(exc), back) + + +@bp.post("/kiosk/restart") +def kiosk_restart(): + return _helper_action("browser_restart", "restart-browser", back=request.referrer or url_for("ui.dashboard")) + + +@bp.get("/kiosk/screenshot.jpg") +def kiosk_screenshot(): + try: + data = svc().cdp.screenshot(quality=70) + except CDPError as exc: + return Response(f"Screenshot niet beschikbaar: {exc}", status=503, mimetype="text/plain") + return Response(data, mimetype="image/jpeg") + + +# -- system ---------------------------------------------------------------------- + +def display_info() -> dict[str, Any]: + try: + return json.loads(svc().paths.display_info_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +@bp.get("/system") +def system(): + info = display_info() + outputs = info.get("outputs") or [] + modes = outputs[0]["modes"] if outputs else [] + return render_template("system.html", active="system", conf=cfg(), modes=modes, + output=outputs[0] if outputs else None, s=collect_status()) + + +@bp.post("/system/display") +def system_display(): + updates = { + "rotation": request.form.get("rotation", "0"), + "resolution": request.form.get("resolution", "auto"), + "zoom": request.form.get("zoom", "1").replace(",", "."), + "screen_always_on": _form_bool("screen_always_on"), + "screen_off_minutes": request.form.get("screen_off_minutes", "10"), + } + if _save(updates) is None: + return redirect(url_for("ui.system")) + audit("display_settings", **{k: v for k, v in updates.items()}) + if _form_bool("apply_now"): + return _helper_action("browser_restart", "restart-browser", back=url_for("ui.system")) + flash("Scherminstellingen opgeslagen. Ze worden actief bij de volgende herstart van Chromium.", "success") + return redirect(url_for("ui.system")) + + +@bp.post("/system/hostname") +def system_hostname(): + return _helper_action("hostname", "set-hostname", request.form.get("hostname", "").strip().lower(), back=url_for("ui.system")) + + +@bp.post("/system/reboot") +def system_reboot(): + return _helper_action("reboot", "reboot", back=url_for("ui.dashboard")) + + +@bp.post("/system/poweroff") +def system_poweroff(): + return _helper_action("poweroff", "poweroff", back=url_for("ui.dashboard")) + + +@bp.post("/system/admin") +def system_admin(): + updates = { + "admin_port": request.form.get("admin_port", "8080"), + "admin_https": _form_bool("admin_https"), + "admin_allow_public": _form_bool("admin_allow_public"), + } + old = cfg() + new = _save(updates) + if new is None: + return redirect(url_for("ui.system")) + audit("admin_settings", **updates) + if any(old[k] != new[k] for k in ("admin_port", "admin_https")): + scheme = "https" if new["admin_https"] else "http" + target = f"{scheme}://{request.host.rsplit(':', 1)[0]}:{new['admin_port']}/" + try: + svc().helper.call("restart-manager") + except HelperError as exc: + flash(f"Opgeslagen, maar herstarten van de beheerinterface mislukte: {exc}", "error") + return redirect(url_for("ui.system")) + return render_template("moved.html", target=target) + flash("Instellingen opgeslagen.", "success") + return redirect(url_for("ui.system")) + + +# -- logs ------------------------------------------------------------------------ + +@bp.get("/logs") +def logs(): + source = request.args.get("source", "kiosk") + if source not in services.LOG_SOURCES: + source = "kiosk" + try: + lines = int(request.args.get("lines", 100)) + except ValueError: + lines = 100 + priority = request.args.get("priority") or None + text = services.journal(source, lines, priority) + return render_template("logs.html", active="logs", text=text, source=source, lines=lines, priority=priority, + sources=services.LOG_SOURCES, line_options=services.LOG_LINES) + + +# -- network ----------------------------------------------------------------------- + +def _pending() -> dict[str, Any] | None: + pending = netinfo.pending_change(svc().paths.network_pending_file) + if pending: + pending = {k: pending.get(k) for k in ("interface", "method", "address", "created", "deadline")} + pending["remaining"] = max(0, int(pending.get("deadline") or 0) - int(time.time())) + return pending + + +@bp.get("/network") +def network(): + return render_template("network.html", active="network", net=netinfo.overview(), pending=_pending(), + rollback_seconds=netconfig.ROLLBACK_SECONDS) + + +@bp.get("/api/network/pending") +def api_network_pending(): + return jsonify(pending=_pending()) + + +@bp.post("/network/apply") +def network_apply(): + interface = request.form.get("interface", "") + payload: dict[str, Any] = { + "interface": interface, + "method": request.form.get("method", "dhcp"), + "address": request.form.get("address", ""), + "gateway": request.form.get("gateway", ""), + "dns": request.form.get("dns", ""), + } + if interface == "wlan0": + payload["wifi"] = { + "ssid": request.form.get("ssid", ""), + "psk": request.form.get("psk", ""), + "hidden": _form_bool("hidden"), + "open": _form_bool("open"), + } + try: + netconfig.validate_request(payload) # early feedback; the helper validates again + svc().helper.call("network-apply", payload=payload, timeout=60) + except (ValidationError, HelperError) as exc: + flash(str(exc), "error") + return redirect(url_for("ui.network")) + audit("network_apply", interface=interface, method=payload["method"], address=payload["address"]) + flash(f"Netwerkwijziging toegepast. Bevestig binnen {netconfig.ROLLBACK_SECONDS // 60} minuten, anders worden de " + "vorige instellingen automatisch teruggezet.", "warning") + return redirect(url_for("ui.network")) + + +@bp.post("/network/confirm") +def network_confirm(): + return _helper_action("network_confirm", "network-confirm", back=url_for("ui.network")) + + +@bp.post("/network/rollback") +def network_rollback(): + return _helper_action("network_rollback", "network-rollback", back=url_for("ui.network")) + + +@bp.post("/network/country") +def network_country(): + return _helper_action("wifi_country", "set-wifi-country", request.form.get("country", "").strip().upper(), back=url_for("ui.network")) + + +@bp.post("/api/network/scan") +def network_scan(): + try: + result = svc().helper.call("wifi-scan", timeout=60) + except HelperError as exc: + return jsonify(error=str(exc)), 500 + return jsonify(networks=result.get("networks", [])) diff --git a/systemd/voxip-boot-import.service b/systemd/voxip-boot-import.service new file mode 100644 index 0000000..145c32d --- /dev/null +++ b/systemd/voxip-boot-import.service @@ -0,0 +1,17 @@ +[Unit] +Description=VoxIP configuratie en SSH-sleutels importeren vanaf de bootpartitie +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +After=local-fs.target +Before=voxip-kiosk-manager.service voxip-kiosk-browser.service ssh.service +ConditionPathExistsGlob=|/boot/firmware/voxip-config.json +ConditionPathExistsGlob=|/boot/firmware/voxip-authorized_keys +ConditionPathExistsGlob=|/boot/voxip-config.json +ConditionPathExistsGlob=|/boot/voxip-authorized_keys + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-helper boot-import + +[Install] +WantedBy=multi-user.target diff --git a/systemd/voxip-kiosk-agent.service b/systemd/voxip-kiosk-agent.service new file mode 100644 index 0000000..c3b0e3c --- /dev/null +++ b/systemd/voxip-kiosk-agent.service @@ -0,0 +1,28 @@ +[Unit] +Description=VoxIP Kiosk agent (navigatie, offlinepagina, verversen) +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +After=voxip-kiosk-browser.service + +[Service] +Type=simple +User=kiosk +Group=kiosk +RuntimeDirectory=voxip-kiosk-agent +RuntimeDirectoryMode=0755 +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-agent +Restart=always +RestartSec=5 +SyslogIdentifier=voxip-kiosk-agent +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes + +[Install] +WantedBy=multi-user.target diff --git a/systemd/voxip-kiosk-browser.service b/systemd/voxip-kiosk-browser.service new file mode 100644 index 0000000..e829a8f --- /dev/null +++ b/systemd/voxip-kiosk-browser.service @@ -0,0 +1,36 @@ +[Unit] +Description=VoxIP Kiosk Chromium +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +Requires=voxip-kiosk-x.service +After=voxip-kiosk-x.service voxip-boot-import.service +# Restarting or stopping X restarts/stops the browser as well. +PartOf=voxip-kiosk-x.service +# A crash loop stops after 6 starts in 10 minutes; the healthcheck logs it +# and retries after a cool-down (see voxip-kiosk-healthcheck). +StartLimitIntervalSec=600 +StartLimitBurst=6 + +[Service] +Type=simple +User=kiosk +Group=kiosk +Environment=DISPLAY=:0 +Environment=XAUTHORITY=/run/voxip-kiosk-x/Xauthority +Environment=HOME=/home/kiosk +RuntimeDirectory=voxip-kiosk-browser +RuntimeDirectoryMode=0755 +StateDirectory=voxip-kiosk-browser +StateDirectoryMode=0700 +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-browser +# voxip-kiosk-browser exits 1 after a crash when browser_restart is on, 0 when it is off. +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillMode=control-group +OOMScoreAdjust=-100 +LogRateLimitIntervalSec=30s +LogRateLimitBurst=300 +SyslogIdentifier=voxip-kiosk-browser + +[Install] +WantedBy=multi-user.target diff --git a/systemd/voxip-kiosk-healthcheck.service b/systemd/voxip-kiosk-healthcheck.service new file mode 100644 index 0000000..144ff6f --- /dev/null +++ b/systemd/voxip-kiosk-healthcheck.service @@ -0,0 +1,11 @@ +[Unit] +Description=VoxIP Kiosk healthcheck +Documentation=file:///usr/share/doc/voxip-kiosk/README.md + +[Service] +Type=oneshot +RuntimeDirectory=voxip-kiosk-health +RuntimeDirectoryPreserve=yes +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-healthcheck +SyslogIdentifier=voxip-kiosk-healthcheck +TimeoutStartSec=60 diff --git a/systemd/voxip-kiosk-healthcheck.timer b/systemd/voxip-kiosk-healthcheck.timer new file mode 100644 index 0000000..769a428 --- /dev/null +++ b/systemd/voxip-kiosk-healthcheck.timer @@ -0,0 +1,10 @@ +[Unit] +Description=VoxIP Kiosk healthcheck (elke minuut) + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min +AccuracySec=10s + +[Install] +WantedBy=timers.target diff --git a/systemd/voxip-kiosk-manager.service b/systemd/voxip-kiosk-manager.service new file mode 100644 index 0000000..53ee65b --- /dev/null +++ b/systemd/voxip-kiosk-manager.service @@ -0,0 +1,31 @@ +[Unit] +Description=VoxIP Kiosk Manager (webbeheer) +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +After=network.target voxip-boot-import.service +StartLimitIntervalSec=300 +StartLimitBurst=10 + +[Service] +Type=simple +User=voxip +Group=voxip +# systemd-journal: read logs; video: vcgencmd (voltage/throttling) +SupplementaryGroups=systemd-journal video +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-manager +Restart=always +RestartSec=3 +UMask=0027 +LogsDirectory=voxip-kiosk +LogsDirectoryMode=0750 +SyslogIdentifier=voxip-kiosk-manager +# No NoNewPrivileges/ProtectSystem/ProtectHome: the helper started through +# "sudo -n voxip-kiosk-helper" inherits this unit's mount namespace and must +# be able to write /etc (hostname, NetworkManager profiles). The web process +# itself runs unprivileged as user voxip and can only write its own dirs. +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +LockPersonality=yes + +[Install] +WantedBy=multi-user.target diff --git a/systemd/voxip-kiosk-x.service b/systemd/voxip-kiosk-x.service new file mode 100644 index 0000000..c8fb502 --- /dev/null +++ b/systemd/voxip-kiosk-x.service @@ -0,0 +1,31 @@ +[Unit] +Description=VoxIP Kiosk X-server (vt7) +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +After=systemd-user-sessions.service systemd-logind.service +Conflicts=getty@tty7.service +StartLimitIntervalSec=600 +StartLimitBurst=10 + +[Service] +Type=simple +User=kiosk +Group=kiosk +# A logind session on tty7 gives the kiosk user access to the display and input devices. +PAMName=login +TTYPath=/dev/tty7 +TTYReset=yes +TTYVHangup=yes +StandardInput=tty +StandardOutput=journal +StandardError=journal +UtmpIdentifier=tty7 +UtmpMode=user +RuntimeDirectory=voxip-kiosk-x +RuntimeDirectoryMode=0700 +ExecStartPre=+/usr/bin/chvt 7 +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-x +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/voxip-network-guard.service b/systemd/voxip-network-guard.service new file mode 100644 index 0000000..7b839cb --- /dev/null +++ b/systemd/voxip-network-guard.service @@ -0,0 +1,16 @@ +[Unit] +Description=VoxIP netwerkherstel bij opstarten (rollback en noodreset) +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +DefaultDependencies=no +After=local-fs.target +Before=NetworkManager.service network-pre.target shutdown.target +Wants=network-pre.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-helper network-boot-guard + +[Install] +WantedBy=multi-user.target diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7b7de79 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,142 @@ +import json +import re +from pathlib import Path + +import pytest + +from voxip_kiosk.paths import Paths + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def paths(tmp_path) -> Paths: + p = Paths( + config_dir=tmp_path / "etc", + state_dir=tmp_path / "state", + network_state_dir=tmp_path / "netstate", + agent_run_dir=tmp_path / "run-agent", + browser_run_dir=tmp_path / "run-browser", + health_run_dir=tmp_path / "run-health", + share_dir=tmp_path / "share", + log_dir=tmp_path / "log", + helper=tmp_path / "helper", + nm_connections_dir=tmp_path / "nm", + ) + for d in (p.config_dir, p.state_dir, p.agent_run_dir, p.browser_run_dir, p.health_run_dir, p.share_dir / "www", + p.log_dir, p.nm_connections_dir): + d.mkdir(parents=True) + (p.share_dir / "VERSION").write_text("9.9.9-test\n") + default = json.loads((ROOT / "config" / "config.default.json").read_text()) + p.config_file.write_text(json.dumps(default)) + return p + + +class FakeHelper: + def __init__(self): + self.calls = [] + self.fail = None + self.responses = {} + + def call(self, verb, *args, payload=None, timeout=60): + from voxip_kiosk.helper_client import HelperError + + self.calls.append((verb, args, payload)) + if self.fail: + raise HelperError(self.fail) + return {"ok": True, "message": f"{verb} ok", **self.responses.get(verb, {})} + + +class FakeCDP: + def __init__(self): + self.calls = [] + self.fail = None + + def _do(self, name, *args): + from voxip_kiosk.cdp import CDPError + + self.calls.append((name, args)) + if self.fail: + raise CDPError(self.fail) + + def reload(self, ignore_cache=False): + self._do("reload") + + def screenshot(self, quality=70): + self._do("screenshot") + return b"\xff\xd8fakejpeg" + + +@pytest.fixture +def fake_helper(): + return FakeHelper() + + +@pytest.fixture +def fake_cdp(): + return FakeCDP() + + +@pytest.fixture +def quiet_system(monkeypatch): + """Keep the web tests independent of the machine they run on.""" + from voxip_kiosk import netinfo, services, sysinfo + + monkeypatch.setattr(services, "summary", lambda: { + "browser": {"unit": services.BROWSER_UNIT, "status": "ok", "label": "Actief", "restarts": "2", "since": ""}, + "x": {"unit": services.X_UNIT, "status": "ok", "label": "Actief", "restarts": "0", "since": ""}, + "agent": {"unit": services.AGENT_UNIT, "status": "ok", "label": "Actief", "restarts": "0", "since": ""}, + "manager": {"unit": services.MANAGER_UNIT, "status": "ok", "label": "Actief", "restarts": "0", "since": ""}, + }) + monkeypatch.setattr(services, "recent_errors", lambda lines=20: []) + monkeypatch.setattr(services, "journal", lambda source, lines=100, priority=None: f"log:{source}:{lines}:{priority}") + monkeypatch.setattr(sysinfo, "ip_addresses", lambda: {"eth0": ["192.168.10.20/24"]}) + monkeypatch.setattr(sysinfo, "throttled", lambda: {"raw": "0x50000", "now": False, "ever": True, "flags": ["Onderspanning opgetreden"]}) + monkeypatch.setattr(netinfo, "overview", lambda: { + "available": True, "wifi_country": "NL", + "devices": { + iface: {"interface": iface, "kind": kind, "present": True, "state": "connected", "state_label": "Verbonden", + "connected": True, "connection": f"voxip-{iface}", "addresses": ["192.168.10.20/24"], "gateway": "192.168.10.1", + "dns": ["192.168.10.1"], "hwaddr": "B8:27:EB:00:00:01", + "profile": {"exists": True, "method": "dhcp", "address": "", "gateway": "", "dns": "", "ssid": "Kantoor", "hidden": False}} + for iface, kind in (("eth0", "ethernet"), ("wlan0", "wifi")) + }, + }) + + +@pytest.fixture +def app(paths, fake_helper, fake_cdp, quiet_system): + from voxip_kiosk.web import create_app + + application = create_app(paths, helper=fake_helper, cdp=fake_cdp) + application.config["TESTING"] = True + return application + + +@pytest.fixture +def client(app): + return app.test_client() + + +def csrf_from(response) -> str: + match = re.search(r'name="csrf-token" content="([^"]+)"', response.get_data(as_text=True)) + assert match, "csrf meta tag missing" + return match.group(1) + + +PASSWORD = "Correct-Paard-Batterij-42" + + +@pytest.fixture +def logged_in(app, client, paths): + """A client with a configured admin password and an active session.""" + app.extensions["voxip"].credentials.set_password(PASSWORD) + page = client.get("/login") + token = csrf_from(page) + resp = client.post("/login", data={"password": PASSWORD, "csrf_token": token}) + assert resp.status_code == 302 and resp.headers["Location"].endswith("/") + return client + + +def token(client) -> str: + return csrf_from(client.get("/")) diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..545afd8 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,171 @@ +import json + +import pytest + +from voxip_kiosk.agent import Agent, HUNG_RELOAD_AFTER, HUNG_RESTART_AFTER +from voxip_kiosk.cdp import CDPError +from voxip_kiosk.config import save_config + + +class Clock: + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + +class BrowserStub: + def __init__(self, url): + self.url = url + self.navigations = [] + self.reloads = 0 + self.closed = False + self.responsive_answer = True + self.down = False + + def main_page(self): + if self.down: + raise CDPError("down") + return {"url": self.url, "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1"} + + def new_page(self, url): + self.url = url + + def navigate(self, url): + self.navigations.append(url) + self.url = url + + def reload(self, ignore_cache=False): + self.reloads += 1 + + def responsive(self, ws=None, timeout=5): + return self.responsive_answer + + def close_browser(self): + self.closed = True + + +class Probe: + def __init__(self): + self.ok = True + self.reason = "" + self.calls = 0 + + def __call__(self, url): + self.calls += 1 + return self.ok, self.reason + + +@pytest.fixture +def env(paths): + paths.admin_file.write_text("{}") # setup finished + clock = Clock() + probe = Probe() + start = f"file://{paths.www_dir}/start.html?next=x" + browser = BrowserStub(start) + agent = Agent(paths, cdp=browser, probe_fn=probe, clock=clock, wall=clock, hostname=lambda: "kiosk", + addresses=lambda: {"eth0": ["192.168.1.20/24"], "wlan0": ["fe80::1/64"]}) + return agent, browser, probe, clock, paths + + +def test_navigates_from_start_page_when_reachable(env): + agent, browser, probe, clock, paths = env + agent.tick() + assert browser.navigations == ["https://schermen-ijsselflat.voxip.nl"] + state = json.loads(paths.agent_state_file.read_text()) + assert state["page"] == "start" and state["target_reachable"] is True + + +def test_offline_page_when_unreachable_then_recovers(env): + agent, browser, probe, clock, _ = env + probe.ok, probe.reason = False, "Naam niet gevonden (DNS)" + agent.tick() + assert "offline.html" in browser.url and "reason=Naam+niet+gevonden" in browser.url + navs = len(browser.navigations) + clock.t += 5 + agent.tick() # still offline: no re-navigation (probe cached / page already offline) + assert len(browser.navigations) == navs + probe.ok = True + clock.t += 11 + agent.tick() + assert browser.url == "https://schermen-ijsselflat.voxip.nl" + + +def test_chromium_error_page_is_replaced(env): + agent, browser, probe, clock, _ = env + browser.url = "chrome-error://chromewebdata/" + probe.ok = False + agent.tick() + assert "offline.html" in browser.url + + +def test_running_page_is_left_alone_when_network_drops(env): + """Pages with their own offline cache (e.g. InfoFlow displays) must keep running.""" + agent, browser, probe, clock, _ = env + browser.url = "https://schermen-ijsselflat.voxip.nl" + agent.tick() + probe.ok = False + for _ in range(20): + clock.t += 61 + agent.tick() + assert browser.navigations == [] + assert browser.reloads == 0 + + +def test_url_change_navigates_without_restart(env): + agent, browser, probe, clock, paths = env + browser.url = "https://schermen-ijsselflat.voxip.nl" + agent.tick() + save_config(paths.config_file, {"url": "https://nieuw.example.nl/scherm"}) + agent.tick() + assert browser.navigations == ["https://nieuw.example.nl/scherm"] + assert not browser.closed + + +def test_refresh_interval_skips_when_unreachable(env): + agent, browser, probe, clock, paths = env + save_config(paths.config_file, {"refresh_interval": 60}) + browser.url = "https://schermen-ijsselflat.voxip.nl" + agent.tick() + clock.t += 61 + agent.tick() + assert browser.reloads == 1 + probe.ok = False + clock.t += 61 + agent.tick() + assert browser.reloads == 1 + assert "overgeslagen" in agent.state["last_error"]["message"] + + +def test_hung_renderer_reload_then_browser_restart(env): + agent, browser, probe, clock, _ = env + browser.url = "https://schermen-ijsselflat.voxip.nl" + browser.responsive_answer = False + for _ in range(HUNG_RELOAD_AFTER): + agent.tick() + assert browser.reloads == 1 and not browser.closed + for _ in range(HUNG_RESTART_AFTER - HUNG_RELOAD_AFTER): + agent.tick() + assert browser.closed + + +def test_setup_page_until_password_exists(env): + agent, browser, probe, clock, paths = env + paths.admin_file.unlink() + paths.setup_code_file.write_text("ABCD-EFGH\n") + agent.tick() + assert "setup.html" in browser.url + assert "code=ABCD-EFGH" in browser.url and "ips=192.168.1.20" in browser.url and "port=8080" in browser.url + agent.tick() + assert len(browser.navigations) == 1 # no flicker: setup page is not reloaded every tick + paths.admin_file.write_text("{}") + agent.tick() + assert browser.url == "https://schermen-ijsselflat.voxip.nl" + + +def test_devtools_down_is_reported(env): + agent, browser, probe, clock, paths = env + browser.down = True + agent.tick() + assert json.loads(paths.agent_state_file.read_text())["browser"] == "unreachable" diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..fb302ec --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,80 @@ +import json +from pathlib import Path + +from voxip_kiosk import browser as b +from voxip_kiosk.config import DEFAULTS + +XRANDR = """Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 7680 x 7680 +HDMI-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 531mm x 299mm + 1920x1080 60.00*+ 50.00 59.94 + 1280x720 60.00 50.00 59.94 + 720x576 50.00 +HDMI-2 disconnected (normal left inverted right x axis y axis) +""" + + +def test_build_args_never_weakens_tls_and_binds_devtools_locally(tmp_path): + args = b.build_args("/usr/bin/chromium", dict(DEFAULTS), tmp_path / "p", tmp_path / "c", "file:///start.html", (1920, 1080)) + joined = " ".join(args) + for flag in b.FORBIDDEN_FLAGS: + assert flag not in joined + assert "--remote-debugging-address=127.0.0.1" in args + assert "--kiosk" in args and "--noerrdialogs" in args and "--disable-session-crashed-bubble" in args + assert "--window-size=1920,1080" in args + assert args[-1] == "file:///start.html" + assert not any(a.startswith("--force-device-scale-factor") for a in args) + + +def test_build_args_zoom(tmp_path): + args = b.build_args("/usr/bin/chromium", dict(DEFAULTS, zoom=1.5), tmp_path, tmp_path, "x") + assert "--force-device-scale-factor=1.5" in args + + +def test_parse_xrandr(): + outputs = b.parse_xrandr(XRANDR) + assert len(outputs) == 1 + out = outputs[0] + assert out["name"] == "HDMI-1" + assert out["modes"] == ["1920x1080", "1280x720", "720x576"] + assert out["current"] == "1920x1080@60.00" + assert out["preferred"] == "1920x1080" + assert out["size"] == (1920, 1080) + + +def test_xrandr_args_rotation_and_resolution(): + out = b.parse_xrandr(XRANDR)[0] + assert b.xrandr_args(out, dict(DEFAULTS, rotation=90)) == ["xrandr", "--output", "HDMI-1", "--auto", "--rotate", "right"] + assert b.xrandr_args(out, dict(DEFAULTS, rotation=270, resolution="1280x720@50")) == [ + "xrandr", "--output", "HDMI-1", "--mode", "1280x720", "--rate", "50", "--rotate", "left"] + # unsupported mode falls back to auto instead of failing + assert "--auto" in b.xrandr_args(out, dict(DEFAULTS, resolution="3840x2160")) + + +def test_xset_commands(): + assert ["xset", "-dpms"] in b.xset_commands(dict(DEFAULTS)) + cmds = b.xset_commands(dict(DEFAULTS, screen_always_on=False, screen_off_minutes=15)) + assert ["xset", "+dpms"] in cmds and ["xset", "dpms", "0", "0", "900"] in cmds + + +def test_clean_profile_removes_locks_and_crash_flag(tmp_path): + profile = tmp_path / "profile" + (profile / "Default").mkdir(parents=True) + (profile / "SingletonLock").symlink_to("kiosk-1234") + (profile / "Default" / "Preferences").write_text(json.dumps({"profile": {"exit_type": "Crashed", "exited_cleanly": False}, "x": 1})) + b.clean_profile(profile) + assert not (profile / "SingletonLock").is_symlink() + prefs = json.loads((profile / "Default" / "Preferences").read_text()) + assert prefs["profile"] == {"exit_type": "Normal", "exited_cleanly": True} + assert prefs["x"] == 1 + + +def test_start_page_url_contains_target(paths): + url = b.start_page_url(paths, dict(DEFAULTS, url="https://e.org/x?token=abc", device_name="Hal 1")) + assert url.startswith(f"file://{paths.www_dir}/start.html?") + assert "next=https%3A%2F%2Fe.org%2Fx%3Ftoken%3Dabc" in url and "name=Hal+1" in url + + +def test_local_pages_exist(): + www = Path(__file__).resolve().parents[1] / "www" + for page in ("start.html", "offline.html", "setup.html", "kiosk.css", "logo.svg"): + assert (www / page).exists() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..49a8e55 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,95 @@ +import json +import os +import stat + +import pytest + +from voxip_kiosk import config as c + + +def test_default_file_matches_defaults(paths): + assert c.load_config(paths.config_file) == c.DEFAULTS + # the default config file itself must be valid + c.validate(json.loads(paths.config_file.read_text())) + + +def test_missing_file_gives_defaults(tmp_path): + assert c.load_config(tmp_path / "nope.json") == c.DEFAULTS + + +def test_invalid_values_fall_back_per_key(paths, caplog): + paths.config_file.write_text(json.dumps({"url": "http://insecure", "rotation": 45, "device_name": "Hal", "zoom": "x"})) + conf = c.load_config(paths.config_file) + assert conf["url"] == c.DEFAULTS["url"] + assert conf["rotation"] == 0 + assert conf["zoom"] == 1.0 + assert conf["device_name"] == "Hal" + assert "Ongeldige waarde" in caplog.text + + +def test_corrupt_file_uses_backup(paths): + c.save_config(paths.config_file, {"device_name": "Eerste"}) + c.save_config(paths.config_file, {"device_name": "Tweede"}) + paths.config_file.write_text("{ kapot") + assert c.load_config(paths.config_file)["device_name"] == "Eerste" + + +def test_save_validates_everything_before_writing(paths): + before = paths.config_file.read_text() + with pytest.raises(c.ConfigError) as exc: + c.save_config(paths.config_file, {"url": "http://x", "refresh_interval": 5, "device_name": "OK"}) + assert set(exc.value.errors) == {"url", "refresh_interval"} + assert paths.config_file.read_text() == before + + +def test_save_rejects_unknown_keys(paths): + with pytest.raises(c.ConfigError, match="Onbekende"): + c.save_config(paths.config_file, {"shell": "rm -rf /"}) + + +def test_save_preserves_unknown_existing_keys_and_writes_backup(paths): + data = json.loads(paths.config_file.read_text()) + data["future_option"] = {"x": 1} + paths.config_file.write_text(json.dumps(data)) + new = c.save_config(paths.config_file, {"refresh_interval": "300", "screen_always_on": False}) + assert new["refresh_interval"] == 300 and new["screen_always_on"] is False + stored = json.loads(paths.config_file.read_text()) + assert stored["future_option"] == {"x": 1} + assert json.loads(paths.config_file.with_suffix(".json.bak").read_text())["refresh_interval"] == 0 + + +def test_atomic_write_leaves_no_temp_files_and_sets_mode(paths): + c.save_config(paths.config_file, {"device_name": "X"}) + leftovers = [p.name for p in paths.config_dir.iterdir() if p.name.endswith(".tmp")] + assert leftovers == [] + assert stat.S_IMODE(os.stat(paths.config_file).st_mode) == 0o640 + + +def test_atomic_write_failure_keeps_old_file(paths, monkeypatch): + before = paths.config_file.read_text() + + def boom(*a, **k): + raise OSError("schijf vol") + + monkeypatch.setattr(c.os, "replace", boom) + with pytest.raises(OSError): + c.atomic_write_json(paths.config_file, {"a": 1}) + assert paths.config_file.read_text() == before + assert not [p for p in paths.config_dir.iterdir() if p.name.endswith(".tmp")] + + +@pytest.mark.parametrize("key, value, expected", [ + ("refresh_interval", 0, 0), ("refresh_interval", "60", 60), ("rotation", "270", 270), + ("zoom", "1.25", 1.25), ("admin_port", 8443, 8443), ("browser_profile", "ephemeral", "ephemeral"), +]) +def test_validate_coerces(key, value, expected): + assert c.validate({key: value})[key] == expected + + +@pytest.mark.parametrize("key, value", [ + ("refresh_interval", 10), ("refresh_interval", -1), ("rotation", 45), ("zoom", 9), ("admin_port", 80), + ("admin_port", True), ("screen_always_on", "yes"), ("browser_profile", "disk"), ("screen_off_minutes", 0), +]) +def test_validate_rejects(key, value): + with pytest.raises(c.ConfigError): + c.validate({key: value}) diff --git a/tests/test_healthcheck.py b/tests/test_healthcheck.py new file mode 100644 index 0000000..7e38272 --- /dev/null +++ b/tests/test_healthcheck.py @@ -0,0 +1,85 @@ +import pytest + +from voxip_kiosk import services +from voxip_kiosk.cdp import CDPError +from voxip_kiosk.config import save_config +from voxip_kiosk.healthcheck import CDP_FAILURES_BEFORE_RESTART, DISPLAY_RETRY_AFTER, HealthCheck + + +class World: + def __init__(self): + self.units = {u: {"ActiveState": "active"} for u in (services.X_UNIT, services.BROWSER_UNIT, services.AGENT_UNIT, services.MANAGER_UNIT)} + self.actions = [] + self.now = 10_000.0 + self.cdp_ok = True + + def unit_status(self, unit): + return self.units[unit] + + def systemctl(self, *args): + self.actions.append(args) + + def version(self): + if not self.cdp_ok: + raise CDPError("timeout") + return {} + + +@pytest.fixture +def world(paths): + w = World() + hc = HealthCheck(paths, unit_status=w.unit_status, systemctl=w.systemctl, cdp=w, throttled=lambda: None, + disk=lambda p: {"percent": 40}, now=lambda: w.now) + return w, hc + + +def test_all_healthy_no_actions(world): + w, hc = world + report = hc.run() + assert report["actions"] == [] and w.actions == [] + + +def test_failed_browser_waits_for_cooldown(world): + w, hc = world + w.units[services.BROWSER_UNIT] = {"ActiveState": "failed", "Result": "start-limit-hit"} + hc.run() + assert w.actions == [] + w.now += DISPLAY_RETRY_AFTER + hc.run() + assert ("reset-failed", services.BROWSER_UNIT) in w.actions + assert ("start", "--no-block", services.BROWSER_UNIT) in w.actions + + +def test_failed_manager_restarts_immediately(world): + w, hc = world + w.units[services.MANAGER_UNIT] = {"ActiveState": "failed"} + hc.run() + assert ("start", "--no-block", services.MANAGER_UNIT) in w.actions + + +def test_browser_restart_disabled_is_respected(world, paths): + w, hc = world + save_config(paths.config_file, {"browser_restart": False}) + w.units[services.BROWSER_UNIT] = {"ActiveState": "inactive"} + hc.run() + assert w.actions == [] + + +def test_unresponsive_devtools_restarts_browser(world): + w, hc = world + hc.run() # browser seen active + w.now += 100 + w.cdp_ok = False + for _ in range(CDP_FAILURES_BEFORE_RESTART - 1): + hc.run() + assert w.actions == [] + hc.run() + assert ("restart", "--no-block", services.BROWSER_UNIT) in w.actions + + +def test_maintenance_mode_blocks_actions(world, paths): + w, hc = world + (paths.health_run_dir / "maintenance").touch() + w.units[services.AGENT_UNIT] = {"ActiveState": "inactive"} + report = hc.run() + assert report["maintenance"] is True and w.actions == [] diff --git a/tests/test_helper.py b/tests/test_helper.py new file mode 100644 index 0000000..4b10fa7 --- /dev/null +++ b/tests/test_helper.py @@ -0,0 +1,201 @@ +import json +import subprocess + +import pytest + +from voxip_kiosk import helper as h +from voxip_kiosk.config import load_config + + +class Recorder: + def __init__(self, outputs=None): + self.commands = [] + self.outputs = outputs or {} + + def __call__(self, args, timeout=30, check=True, input=None): + self.commands.append(list(args)) + key = " ".join(args[:3]) + return subprocess.CompletedProcess(args, 0, stdout=self.outputs.get(key, ""), stderr="") + + def ran(self, *prefix): + return [c for c in self.commands if c[: len(prefix)] == list(prefix)] + + +@pytest.fixture +def boot(tmp_path): + d = tmp_path / "boot" + d.mkdir() + (d / "config.txt").write_text("") + return d + + +@pytest.fixture +def helper(paths, tmp_path, boot): + etc = tmp_path / "sysetc" + etc.mkdir() + (etc / "hosts").write_text("127.0.0.1\tlocalhost\n127.0.1.1\tkiosk\n") + run = Recorder({"nmcli -t -f": "aaaa-1111\n"}) + return h.Helper(paths=paths, run=run, etc=etc, boot_dirs=(boot,), helper_path="/usr/lib/voxip-kiosk/voxip-kiosk-helper") + + +def run_main(helper, argv, env=None, capsys=None): + code = h.main(argv, helper=helper, environ=env or {}) + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + return code, out + + +def test_internal_verbs_refused_via_sudo(helper, capsys): + code, out = run_main(helper, ["network-restore"], {"SUDO_USER": "voxip"}, capsys) + assert code == 1 and out["ok"] is False + code, out = run_main(helper, ["boot-import"], {"SUDO_USER": "voxip"}, capsys) + assert code == 1 + assert helper.run.commands == [] + + +def test_public_verb_via_sudo(helper, capsys): + code, out = run_main(helper, ["restart-browser"], {"SUDO_USER": "voxip"}, capsys) + assert code == 0 and out["ok"] is True + assert helper.run.ran("systemctl", "restart", "--no-block", "voxip-kiosk-browser.service") + + +def test_unknown_verb_and_bad_arity(helper, capsys): + assert run_main(helper, ["rm", "-rf", "/"], capsys=capsys)[0] == 1 + assert run_main(helper, ["reboot", "now"], capsys=capsys)[0] == 1 + assert run_main(helper, ["set-hostname"], capsys=capsys)[0] == 1 + assert helper.run.commands == [] + + +def test_requires_root_without_injected_helper(capsys, monkeypatch): + monkeypatch.setattr(h.os, "geteuid", lambda: 1000) + assert h.main(["reboot"], environ={}) == 1 + assert "root" in capsys.readouterr().out + + +def test_set_hostname(helper, capsys): + code, out = run_main(helper, ["set-hostname", "Hal-BG"], capsys=capsys) + assert code == 0 and out["hostname"] == "hal-bg" + assert (helper.etc / "hostname").read_text() == "hal-bg\n" + hosts = (helper.etc / "hosts").read_text() + assert "127.0.1.1\thal-bg" in hosts and "127.0.1.1\tkiosk" not in hosts and "localhost" in hosts + assert helper.run.ran("hostname", "hal-bg") + + +@pytest.mark.parametrize("bad", ["kiosk; reboot", "../etc", "$(id)", "-x", ""]) +def test_set_hostname_rejects_injection(helper, capsys, bad): + code, out = run_main(helper, ["set-hostname", bad], capsys=capsys) + assert code == 1 + assert helper.run.commands == [] + + +def test_reboot_is_delayed_through_systemd_run(helper): + helper.reboot() + assert helper.run.commands[0][:2] == ["systemd-run", "--on-active=3"] + assert helper.run.commands[0][-2:] == ["/bin/systemctl", "reboot"] + + +ETH_STATIC = {"interface": "eth0", "method": "static", "address": "192.168.5.10/24", "gateway": "192.168.5.1", "dns": ["192.168.5.1"]} + + +def test_network_apply_snapshot_pending_and_timers(helper, paths): + (paths.nm_connections_dir / "Wired connection 1.nmconnection").write_text("[connection]\nid=Wired connection 1\n") + result = helper.network_apply(ETH_STATIC) + keyfile = paths.nm_connections_dir / "voxip-eth0.nmconnection" + assert keyfile.exists() and oct(keyfile.stat().st_mode & 0o777) == "0o600" + assert "address1=192.168.5.10/24,192.168.5.1" in keyfile.read_text() + assert (paths.network_backup_dir / "Wired connection 1.nmconnection").exists() + pending = json.loads(paths.network_pending_file.read_text()) + assert pending["active_before"] == ["aaaa-1111"] + assert pending["deadline"] - pending["created"] == 180 + assert result["pending"]["interface"] == "eth0" + runs = [c for c in helper.run.commands if c[0] == "systemd-run"] + assert runs[0][1:3] == ["--unit=voxip-network-rollback", "--on-active=180"] and runs[0][-1] == "network-restore" + assert runs[1][1:3] == ["--unit=voxip-network-activate", "--on-active=2"] and runs[1][-2:] == ["network-activate", "eth0"] + + +def test_second_apply_refused_while_pending(helper): + helper.network_apply(ETH_STATIC) + with pytest.raises(h.HelperError, match="open"): + helper.network_apply(ETH_STATIC) + + +def test_confirm_clears_pending(helper, paths): + helper.network_apply(ETH_STATIC) + helper.network_confirm() + assert not paths.network_pending_file.exists() + assert not paths.network_backup_dir.exists() + assert (paths.nm_connections_dir / "voxip-eth0.nmconnection").exists() + assert helper.run.ran("systemctl", "stop", "voxip-network-rollback.timer") + + +def test_restore_puts_old_profiles_back(helper, paths): + old = paths.nm_connections_dir / "Wired connection 1.nmconnection" + old.write_text("[connection]\nid=Wired connection 1\n") + helper.network_apply(ETH_STATIC) + old.unlink() + helper.network_restore() + assert old.exists() + assert not (paths.nm_connections_dir / "voxip-eth0.nmconnection").exists() + assert not paths.network_pending_file.exists() + assert helper.run.ran("nmcli", "--wait", "30", "connection", "up", "uuid", "aaaa-1111") + + +def test_public_rollback_is_asynchronous(helper): + helper.network_apply(ETH_STATIC) + helper.run.commands.clear() + helper.network_rollback() + runs = [c for c in helper.run.commands if c[0] == "systemd-run"] + assert runs and runs[0][2] == "--on-active=1" and runs[0][-1] == "network-restore" + + +def test_wifi_keeps_existing_password_for_same_ssid(helper, paths): + helper.network_apply({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Kantoor", "psk": "geheim-wachtwoord"}}) + helper.network_confirm() + helper.network_apply({"interface": "wlan0", "method": "static", "address": "10.0.0.9/24", "gateway": "10.0.0.1", "dns": "10.0.0.1", + "wifi": {"ssid": "Kantoor", "psk": ""}}) + assert "psk=geheim-wachtwoord" in (paths.nm_connections_dir / "voxip-wlan0.nmconnection").read_text() + helper.network_confirm() + with pytest.raises(h.HelperError, match="wachtwoord"): + helper.network_apply({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Ander", "psk": ""}}) + + +def test_boot_guard_rolls_back_and_honours_reset(helper, paths, boot): + old = paths.nm_connections_dir / "Wired connection 1.nmconnection" + old.write_text("x") + helper.network_apply(ETH_STATIC) + helper.run.commands.clear() + result = helper.network_boot_guard() + assert result == {"rolled_back": True} + assert not (paths.nm_connections_dir / "voxip-eth0.nmconnection").exists() + assert not any(c[0] == "nmcli" for c in helper.run.commands), "NetworkManager is not running yet at boot" + + (paths.nm_connections_dir / "voxip-wlan0.nmconnection").write_text("x") + (boot / "voxip-network-reset").write_text("") + assert helper.network_boot_guard() == {"reset": True} + assert not (paths.nm_connections_dir / "voxip-wlan0.nmconnection").exists() + assert not (boot / "voxip-network-reset").exists() + assert old.exists() + + +def test_boot_import_applies_config(helper, paths, boot): + (boot / "voxip-config.json").write_text(json.dumps({"device_name": "Hal", "url": "https://klant.example.nl/display/hal?token=x", + "hostname": "hal", "rotation": 90})) + assert helper.boot_import() == {"imported": ["voxip-config.json"]} + conf = load_config(paths.config_file) + assert conf["device_name"] == "Hal" and conf["rotation"] == 90 and conf["url"].startswith("https://klant") + assert (helper.etc / "hostname").read_text() == "hal\n" + assert (boot / "voxip-config.json.applied").exists() + + +def test_boot_import_rejects_invalid_config(helper, paths, boot): + before = paths.config_file.read_text() + (boot / "voxip-config.json").write_text(json.dumps({"url": "http://onveilig.example"})) + helper.boot_import() + assert paths.config_file.read_text() == before + assert (boot / "voxip-config.json.rejected").exists() + assert "HTTPS" in (boot / "voxip-config.error.txt").read_text() + + +def test_boot_import_rejects_non_key_lines(helper, boot): + (boot / "voxip-authorized_keys").write_text("dit is geen sleutel\n") + helper.boot_import() + assert (boot / "voxip-authorized_keys.rejected").exists() diff --git a/tests/test_netconfig.py b/tests/test_netconfig.py new file mode 100644 index 0000000..257cb45 --- /dev/null +++ b/tests/test_netconfig.py @@ -0,0 +1,79 @@ +import pytest + +from voxip_kiosk import netconfig as n +from voxip_kiosk.validation import ValidationError + + +def test_validate_static_ethernet(): + req = n.validate_request({"interface": "eth0", "method": "static", "address": "192.168.1.50/24", + "gateway": "192.168.1.1", "dns": "1.1.1.1, 9.9.9.9"}) + assert req == {"interface": "eth0", "method": "static", "address": "192.168.1.50/24", "gateway": "192.168.1.1", "dns": ["1.1.1.1", "9.9.9.9"]} + + +@pytest.mark.parametrize("payload, fragment", [ + ({"interface": "eth1", "method": "dhcp"}, "interface"), + ({"interface": "eth0", "method": "manual"}, "DHCP"), + ({"interface": "eth0", "method": "static", "address": "192.168.1.50/24", "gateway": "10.0.0.1", "dns": "1.1.1.1"}, "subnet"), + ({"interface": "eth0", "method": "static", "address": "192.168.1.50/24", "gateway": "192.168.1.1", "dns": ""}, "DNS"), + ({"interface": "wlan0", "method": "dhcp"}, "Wifi"), + ("not a dict", "Ongeldig"), +]) +def test_validate_rejects(payload, fragment): + with pytest.raises(ValidationError, match=fragment): + n.validate_request(payload) + + +def test_keyfile_static_ethernet(): + req = n.validate_request({"interface": "eth0", "method": "static", "address": "10.1.2.3/16", "gateway": "10.1.0.1", "dns": ["10.1.0.1"]}) + text = n.build_keyfile(req, uuid="11111111-2222-3333-4444-555555555555") + kf = n.read_keyfile(text) + assert kf.get("connection", "id") == "voxip-eth0" + assert kf.get("connection", "type") == "ethernet" + assert kf.get("connection", "autoconnect-priority") == "100" + assert kf.get("ipv4", "method") == "manual" + assert kf.get("ipv4", "address1") == "10.1.2.3/16,10.1.0.1" + assert kf.get("ipv4", "dns") == "10.1.0.1;" + assert kf.get("ipv4", "ignore-auto-dns") == "true" + + +def test_keyfile_wifi_escapes_values(): + req = n.validate_request({"interface": "wlan0", "method": "dhcp", "dns": "", + "wifi": {"ssid": " Café\\Net", "psk": "p\\ss word!", "hidden": True}}) + text = n.build_keyfile(req, psk=req["wifi"]["psk"]) + kf = n.read_keyfile(text) + assert n.unescape(kf.get("wifi", "ssid")) == " Café\\Net" + assert kf.get("wifi", "ssid").startswith("\\s") + assert n.unescape(kf.get("wifi-security", "psk")) == "p\\ss word!" + assert kf.get("wifi", "hidden") == "true" + assert kf.get("ipv4", "method") == "auto" + assert not kf.has_option("ipv4", "dns") + + +def test_keyfile_open_wifi_has_no_security_section(): + req = n.validate_request({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Gasten", "open": True}}) + assert "[wifi-security]" not in n.build_keyfile(req) + + +def test_keyfile_requires_psk_for_protected_wifi(): + req = n.validate_request({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Kantoor"}}) + with pytest.raises(ValidationError): + n.build_keyfile(req, psk="") + + +def test_split_terse_handles_escapes(): + assert n.split_terse(r"*:My\:Net:80:WPA2") == ["*", "My:Net", "80", "WPA2"] + assert n.split_terse(r" :back\\slash:40:") == [" ", "back\\slash", "40", ""] + + +def test_parse_wifi_scan_dedupes_and_sorts(): + out = "\n".join([ + " :Buren:40:WPA2", + " :Kantoor:55:WPA2", + "*:Kantoor:70:WPA2", + " ::90:WPA2", # hidden network without SSID + " :Gasten:80:", + ]) + nets = n.parse_wifi_scan(out) + assert [x["ssid"] for x in nets] == ["Kantoor", "Gasten", "Buren"] + assert nets[0]["in_use"] is True and nets[0]["signal"] == 70 + assert nets[1]["security"] == "open" diff --git a/tests/test_sysinfo.py b/tests/test_sysinfo.py new file mode 100644 index 0000000..1fc0b4c --- /dev/null +++ b/tests/test_sysinfo.py @@ -0,0 +1,46 @@ +from voxip_kiosk import services, sysinfo + + +def test_parse_throttled(): + assert sysinfo.parse_throttled("throttled=0x0") == {"raw": "0x0", "now": False, "ever": False, "flags": []} + info = sysinfo.parse_throttled("throttled=0x50005") + assert info["now"] and info["ever"] + assert "Onderspanning" in info["flags"] and "Onderspanning opgetreden" in info["flags"] + assert sysinfo.parse_throttled("garbage") is None + + +def test_parse_ip_json_keeps_global_addresses(): + data = [ + {"ifname": "lo", "addr_info": [{"local": "127.0.0.1", "prefixlen": 8, "scope": "host"}]}, + {"ifname": "eth0", "addr_info": [{"local": "192.168.1.5", "prefixlen": 24, "scope": "global"}, + {"local": "fe80::1", "prefixlen": 64, "scope": "link"}]}, + {"ifname": "wlan0", "addr_info": []}, + ] + assert sysinfo.parse_ip_json(data) == {"eth0": ["192.168.1.5/24"]} + + +def test_proc_readers(tmp_path): + (tmp_path / "uptime").write_text("3725.5 100.0\n") + (tmp_path / "meminfo").write_text("MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n") + (tmp_path / "stat").write_text("cpu 100 0 100 800 0 0 0 0 0 0\n") + assert sysinfo.uptime_seconds(str(tmp_path)) == 3725.5 + assert sysinfo.memory(str(tmp_path)) == {"total": 1024000, "used": 768000, "percent": 75.0} + assert sysinfo.cpu_percent(str(tmp_path)) is not None + + +def test_formatting(): + assert sysinfo.format_duration(3725) == "1 u 2 min" + assert sysinfo.format_duration(90061) == "1 d 1 u 1 min" + assert sysinfo.format_bytes(1536 * 1024 * 1024) == "1,5 GB" + + +def test_describe_units(): + assert services.describe({"ActiveState": "active"}) == ("ok", "Actief") + assert services.describe({"ActiveState": "failed"})[0] == "error" + assert services.describe({"ActiveState": "inactive", "Result": "exit-code"})[0] == "error" + assert services.describe({"ActiveState": "inactive", "Result": "success"})[0] == "stopped" + assert services.describe({})[0] == "unknown" + + +def test_parse_show(): + assert services.parse_show("ActiveState=active\nNRestarts=3\n") == {"ActiveState": "active", "NRestarts": "3"} diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..82b670d --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,95 @@ +import pytest + +from voxip_kiosk import validation as v + + +@pytest.mark.parametrize("url", [ + "https://schermen-ijsselflat.voxip.nl", + "https://portal.example.nl/display/ab-flat?token=abc123", + "https://10.0.0.5:8443/scherm", + " https://example.org/ ", +]) +def test_kiosk_url_accepts_https(url): + assert v.kiosk_url(url) == url.strip() + + +@pytest.mark.parametrize("url, fragment", [ + ("http://example.org", "HTTPS"), + ("ftp://example.org", "HTTPS"), + ("javascript:alert(1)", "HTTPS"), + ("file:///etc/passwd", "HTTPS"), + ("https://", "hostnaam"), + ("https://user:pw@example.org", "wachtwoord"), + ("https://exa mple.org", "spaties"), + ("https://example.org/\nX", "stuurtekens"), + ("https://exa_mple.org", "hostnaam"), + ("", "Vul"), + ("https://example.org/" + "a" * 2100, "lang"), +]) +def test_kiosk_url_rejects(url, fragment): + with pytest.raises(v.ValidationError, match=fragment): + v.kiosk_url(url) + + +def test_mask_url_hides_tokens_only(): + masked = v.mask_url("https://portal.example.nl/display/ab?token=abcdefghijkl1234&theme=dark") + assert "abcdefghijkl" not in masked + assert "token=…1234" in masked + assert "theme=dark" in masked + assert v.mask_url("https://example.org/x") == "https://example.org/x" + assert "secret" not in v.mask_url("https://e.org/?key=secret") + + +@pytest.mark.parametrize("name, ok", [ + ("kiosk", True), ("Hal-BG", True), ("a", True), ("-bad", False), ("bad-", False), + ("with space", False), ("x" * 64, False), ("localhost", False), ("kiosk.local", False), ("", False), +]) +def test_hostname(name, ok): + if ok: + assert v.hostname(name) == name.lower() + else: + with pytest.raises(v.ValidationError): + v.hostname(name) + + +def test_resolution(): + assert v.resolution("auto") == "auto" + assert v.resolution("1920x1080") == "1920x1080" + assert v.resolution("1280X720@60") == "1280x720@60" + assert v.parse_resolution("1920x1080@59.94") == ("1920x1080", "59.94") + assert v.parse_resolution("auto") is None + for bad in ("1920", "99999x1", "1920x1080@", "big"): + with pytest.raises(v.ValidationError): + v.resolution(bad) + + +def test_ip_helpers(): + assert v.ipv4_interface("192.168.1.50/24") == "192.168.1.50/24" + with pytest.raises(v.ValidationError): + v.ipv4_interface("192.168.1.50") + with pytest.raises(v.ValidationError, match="netwerk- of broadcast"): + v.ipv4_interface("192.168.1.0/24") + with pytest.raises(v.ValidationError): + v.ipv4_interface("192.168.1.300/24") + assert v.dns_servers("1.1.1.1, 9.9.9.9;2606:4700:4700::1111") == ["1.1.1.1", "9.9.9.9", "2606:4700:4700::1111"] + assert v.dns_servers("") == [] + with pytest.raises(v.ValidationError): + v.dns_servers("1.1.1.1 2.2.2.2 3.3.3.3 4.4.4.4") + with pytest.raises(v.ValidationError): + v.dns_servers("dns.google") + + +def test_wifi_values(): + assert v.ssid("Kantoor 2.4GHz") == "Kantoor 2.4GHz" + with pytest.raises(v.ValidationError): + v.ssid("x" * 33) + with pytest.raises(v.ValidationError): + v.ssid("bad\nssid") + assert v.wifi_psk("geheim123") == "geheim123" + assert v.wifi_psk("a" * 64) == "a" * 64 + for bad in ("kort", "x" * 64, "geheimé123"): + with pytest.raises(v.ValidationError): + v.wifi_psk(bad) + assert v.country_code("nl") == "NL" + with pytest.raises(v.ValidationError): + v.country_code("NLD") diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..98168a8 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,300 @@ +import json + +import pytest + +from voxip_kiosk.config import load_config +from voxip_kiosk.web import auth + +from .conftest import PASSWORD, csrf_from, token + + +# --- initial setup ------------------------------------------------------------- + +def test_everything_redirects_to_setup_until_configured(client, paths): + for path in ("/", "/kiosk", "/system", "/login", "/network", "/logs"): + resp = client.get(path) + assert resp.status_code == 302 and resp.headers["Location"].endswith("/setup"), path + assert paths.setup_code_file.exists() + assert oct(paths.setup_code_file.stat().st_mode & 0o777) == "0o640" + + +def test_setup_requires_code_and_strong_password(client, paths): + page = client.get("/setup") + tok = csrf_from(page) + code = paths.setup_code_file.read_text().strip() + + resp = client.post("/setup", data={"csrf_token": tok, "setup_code": "AAAA-BBBB", "password": PASSWORD, "password_confirm": PASSWORD}) + assert "setupcode klopt niet" in resp.get_data(as_text=True) + assert not paths.admin_file.exists() + + resp = client.post("/setup", data={"csrf_token": tok, "setup_code": code, "password": "admin12345", "password_confirm": "admin12345"}) + assert "te eenvoudig" in resp.get_data(as_text=True) + + resp = client.post("/setup", data={"csrf_token": tok, "setup_code": code.lower().replace("-", ""), "password": PASSWORD, "password_confirm": PASSWORD}) + assert resp.status_code == 302 + stored = json.loads(paths.admin_file.read_text()) + assert stored["password_hash"].startswith("$argon2id$") + assert PASSWORD not in paths.admin_file.read_text() + assert oct(paths.admin_file.stat().st_mode & 0o777) == "0o600" + assert not paths.setup_code_file.exists() + assert client.get("/").status_code == 200 + assert client.get("/setup").status_code == 302 + + +def test_setup_without_csrf_is_rejected(client, paths): + client.get("/setup") + code = paths.setup_code_file.read_text().strip() + resp = client.post("/setup", data={"setup_code": code, "password": PASSWORD, "password_confirm": PASSWORD}) + assert resp.status_code == 303 + assert not paths.admin_file.exists() + + +# --- login & session ------------------------------------------------------------- + +def test_login_logout(app, client): + app.extensions["voxip"].credentials.set_password(PASSWORD) + assert client.get("/").status_code == 302 + tok = csrf_from(client.get("/login")) + assert "Onjuist" in client.post("/login", data={"csrf_token": tok, "password": "fout-wachtwoord"}).get_data(as_text=True) + assert client.post("/login", data={"csrf_token": tok, "password": PASSWORD}).status_code == 302 + assert client.get("/").status_code == 200 + client.post("/logout", data={"csrf_token": token(client)}) + assert client.get("/").status_code == 302 + + +def test_session_cookie_flags(app, client): + app.extensions["voxip"].credentials.set_password(PASSWORD) + tok = csrf_from(client.get("/login")) + resp = client.post("/login", data={"csrf_token": tok, "password": PASSWORD}) + cookie = resp.headers["Set-Cookie"] + assert "HttpOnly" in cookie and "SameSite=Strict" in cookie + + +def test_login_throttling(app, client): + app.extensions["voxip"].credentials.set_password(PASSWORD) + tok = csrf_from(client.get("/login")) + for _ in range(5): + client.post("/login", data={"csrf_token": tok, "password": "verkeerd-wachtwoord"}) + resp = client.post("/login", data={"csrf_token": tok, "password": PASSWORD}) + assert "Te veel mislukte pogingen" in resp.get_data(as_text=True) + assert client.get("/").status_code == 302 + + +def test_throttle_backoff_and_global_brake(): + now = [0.0] + t = auth.LoginThrottle(clock=lambda: now[0], global_limit=8) + for _ in range(4): + t.failure("1.1.1.1") + assert t.retry_after("1.1.1.1") == 0 + t.failure("1.1.1.1") + assert t.retry_after("1.1.1.1") == 30 + t.failure("1.1.1.1") + assert t.retry_after("1.1.1.1") == 60 + now[0] += 61 + assert t.retry_after("1.1.1.1") == 0 + t.success("1.1.1.1") + for i in range(8): + t.failure(f"10.0.0.{i}") + assert t.retry_after("192.168.0.99") > 0 + + +def test_password_change_invalidates_other_sessions(app, logged_in): + other = app.test_client() + tok = csrf_from(other.get("/login")) + other.post("/login", data={"csrf_token": tok, "password": PASSWORD}) + assert other.get("/").status_code == 200 + + new = "Nieuw-En-Lang-Wachtwoord-7" + resp = logged_in.post("/account/password", data={"csrf_token": token(logged_in), "current_password": PASSWORD, + "password": new, "password_confirm": new}) + assert resp.status_code == 302 + assert logged_in.get("/").status_code == 200 + assert other.get("/").status_code == 302 + + +def test_idle_session_expires(app, logged_in, monkeypatch): + from voxip_kiosk.web import views + + real = views.time.time + monkeypatch.setattr(views.time, "time", lambda: real() + views.IDLE_TIMEOUT + 5) + assert logged_in.get("/").status_code == 302 + + +def test_password_policy(): + assert auth.password_problem("kort") is not None + assert auth.password_problem("aaaaaaaaaaaa") is not None + assert auth.password_problem("Password123") is not None + assert auth.password_problem("lang-genoeg-en-uniek", "anders") == "De wachtwoorden komen niet overeen." + assert auth.password_problem("lang-genoeg-en-uniek", "lang-genoeg-en-uniek") is None + + +# --- network access & headers ------------------------------------------------------ + +@pytest.mark.parametrize("addr, allowed", [ + ("192.168.1.10", True), ("10.1.2.3", True), ("172.16.0.1", True), ("127.0.0.1", True), ("fe80::1", True), + ("fd00::5", True), ("100.101.102.103", True), ("::ffff:192.168.1.10", True), + ("8.8.8.8", False), ("2001:4860::8888", False), ("::ffff:8.8.8.8", False), +]) +def test_public_clients_blocked(client, addr, allowed): + resp = client.get("/login", environ_base={"REMOTE_ADDR": addr}) + assert (resp.status_code != 403) == allowed, addr + + +def test_same_subnet_global_ipv6_allowed(): + from voxip_kiosk.web.security import client_allowed + + assert client_allowed("2a02:a210:1:2::99", False, ["2a02:a210:1:2::5/64"]) + assert not client_allowed("2a02:a210:9:9::99", False, ["2a02:a210:1:2::5/64"]) + assert client_allowed("8.8.8.8", True) + + +def test_security_headers(client): + resp = client.get("/setup") + assert "default-src 'self'" in resp.headers["Content-Security-Policy"] + assert resp.headers["X-Frame-Options"] == "DENY" + assert resp.headers["Cache-Control"] == "no-store" + + +def test_api_requires_login(app, client): + app.extensions["voxip"].credentials.set_password(PASSWORD) + resp = client.get("/api/status") + assert resp.status_code == 401 and resp.json["error"] + + +def test_post_without_csrf_rejected(logged_in, fake_helper): + resp = logged_in.post("/system/reboot") + assert resp.status_code == 303 + assert fake_helper.calls == [] + resp = logged_in.post("/system/reboot", data={"csrf_token": token(logged_in)}, headers={"Origin": "http://evil.example"}) + assert fake_helper.calls == [] + + +# --- pages --------------------------------------------------------------------------- + +@pytest.mark.parametrize("path", ["/", "/kiosk", "/system", "/network", "/logs", "/logs?source=network&lines=50", "/account/password"]) +def test_pages_render(logged_in, path): + resp = logged_in.get(path) + assert resp.status_code == 200, path + body = resp.get_data(as_text=True) + assert "VoxIP Kiosk Manager" in body and "9.9.9-test" in body + + +def test_dashboard_masks_token(logged_in, paths): + from voxip_kiosk.config import save_config + + save_config(paths.config_file, {"url": "https://portal.example.nl/display/hal?token=supergeheimtoken99"}) + body = logged_in.get("/").get_data(as_text=True) + assert "supergeheimtoken" not in body and "token=…en99" in body + assert "supergeheimtoken" not in json.dumps(logged_in.get("/api/status").json) + + +def test_api_status_shape(logged_in): + data = logged_in.get("/api/status").json + for key in ("browser", "url", "uptime", "cpu", "temperature", "memory", "disk", "eth0", "wlan0", "network", "version"): + assert key in data + assert data["eth0"] == "192.168.10.20/24" + assert data["power"]["status"] == "warning" + + +# --- actions ------------------------------------------------------------------------- + +def test_kiosk_settings_valid_and_invalid(logged_in, paths): + tok = token(logged_in) + form = {"csrf_token": tok, "url": "https://nieuw.example.nl/scherm", "device_name": "Hal", "refresh_interval": "300", + "browser_restart": "1", "browser_profile": "ephemeral"} + logged_in.post("/kiosk/settings", data=form) + conf = load_config(paths.config_file) + assert conf["url"] == "https://nieuw.example.nl/scherm" and conf["refresh_interval"] == 300 + assert conf["browser_profile"] == "ephemeral" + + resp = logged_in.post("/kiosk/settings", data=dict(form, url="http://onveilig.example"), follow_redirects=True) + assert "Alleen HTTPS" in resp.get_data(as_text=True) + assert load_config(paths.config_file)["url"] == "https://nieuw.example.nl/scherm" + + logged_in.post("/kiosk/settings", data={k: v for k, v in form.items() if k != "browser_restart"}) + assert load_config(paths.config_file)["browser_restart"] is False + + +def test_reload_uses_devtools_not_restart(logged_in, fake_cdp, fake_helper): + logged_in.post("/kiosk/reload", data={"csrf_token": token(logged_in)}) + assert fake_cdp.calls == [("reload", ())] + assert fake_helper.calls == [] + + +def test_reload_failure_is_reported(logged_in, fake_cdp): + fake_cdp.fail = "DevTools niet bereikbaar" + resp = logged_in.post("/kiosk/reload", data={"csrf_token": token(logged_in)}, follow_redirects=True) + assert "Herladen mislukt" in resp.get_data(as_text=True) + + +@pytest.mark.parametrize("path, verb", [ + ("/kiosk/restart", "restart-browser"), ("/system/reboot", "reboot"), ("/system/poweroff", "poweroff"), + ("/network/confirm", "network-confirm"), ("/network/rollback", "network-rollback"), +]) +def test_helper_actions(logged_in, fake_helper, path, verb): + logged_in.post(path, data={"csrf_token": token(logged_in)}) + assert fake_helper.calls[-1][0] == verb + + +def test_restart_and_reboot_are_separate(logged_in, fake_helper): + logged_in.post("/kiosk/restart", data={"csrf_token": token(logged_in)}) + assert [c[0] for c in fake_helper.calls] == ["restart-browser"] + + +def test_hostname_passed_as_single_argument(logged_in, fake_helper): + logged_in.post("/system/hostname", data={"csrf_token": token(logged_in), "hostname": "Hal-BG"}) + assert fake_helper.calls[-1] == ("set-hostname", ("hal-bg",), None) + + +def test_display_settings_saved_and_applied(logged_in, fake_helper, paths): + logged_in.post("/system/display", data={"csrf_token": token(logged_in), "rotation": "90", "resolution": "1280x720", + "zoom": "1,25", "screen_off_minutes": "10", "apply_now": "1"}) + conf = load_config(paths.config_file) + assert conf["rotation"] == 90 and conf["resolution"] == "1280x720" and conf["zoom"] == 1.25 + assert conf["screen_always_on"] is False + assert fake_helper.calls[-1][0] == "restart-browser" + + +def test_network_apply_validates_before_helper(logged_in, fake_helper): + tok = token(logged_in) + resp = logged_in.post("/network/apply", data={"csrf_token": tok, "interface": "eth0", "method": "static", + "address": "192.168.1.50", "gateway": "", "dns": ""}, follow_redirects=True) + assert "prefixlengte" in resp.get_data(as_text=True) + assert fake_helper.calls == [] + logged_in.post("/network/apply", data={"csrf_token": tok, "interface": "wlan0", "method": "dhcp", "ssid": "Kantoor", + "psk": "geheim-wachtwoord", "dns": ""}) + verb, args, payload = fake_helper.calls[-1] + assert verb == "network-apply" and args == () + assert payload["wifi"] == {"ssid": "Kantoor", "psk": "geheim-wachtwoord", "hidden": False, "open": False} + + +def test_wifi_scan_json(logged_in, fake_helper): + fake_helper.responses["wifi-scan"] = {"networks": [{"ssid": "Kantoor", "signal": 70, "security": "WPA2", "in_use": True}]} + resp = logged_in.post("/api/network/scan", headers={"X-CSRF-Token": token(logged_in), "Accept": "application/json"}) + assert resp.status_code == 200 and resp.json["networks"][0]["ssid"] == "Kantoor" + + +def test_helper_error_is_shown(logged_in, fake_helper): + fake_helper.fail = "Opdracht niet toegestaan" + resp = logged_in.post("/system/reboot", data={"csrf_token": token(logged_in)}, follow_redirects=True) + assert "Opdracht niet toegestaan" in resp.get_data(as_text=True) + + +def test_screenshot(logged_in, fake_cdp): + resp = logged_in.get("/kiosk/screenshot.jpg") + assert resp.status_code == 200 and resp.mimetype == "image/jpeg" + fake_cdp.fail = "weg" + assert logged_in.get("/kiosk/screenshot.jpg").status_code == 503 + + +def test_audit_log_written(logged_in, paths): + logged_in.post("/kiosk/reload", data={"csrf_token": token(logged_in)}) + events = [json.loads(line)["event"] for line in paths.audit_log.read_text().splitlines()] + assert "login" in events and "page_reload" in events + + +def test_admin_port_change_restarts_manager(logged_in, fake_helper, paths): + resp = logged_in.post("/system/admin", data={"csrf_token": token(logged_in), "admin_port": "8443"}) + assert resp.status_code == 200 and ":8443/" in resp.get_data(as_text=True) + assert fake_helper.calls[-1][0] == "restart-manager" + assert load_config(paths.config_file)["admin_port"] == 8443 diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..bf21ec7 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Remove VoxIP Kiosk OS. +# +# sudo ./uninstall.sh # remove programs and services, keep configuration +# sudo ./uninstall.sh --purge # also remove configuration, state, logs and users +# +# Installed Debian packages (Chromium, Xorg, Flask, …) are left in place; remove +# them with apt if they are no longer needed. + +set -euo pipefail + +PURGE=0 +[[ ${1:-} == "--purge" ]] && PURGE=1 +[[ ${1:-} == "-h" || ${1:-} == "--help" ]] && { sed -n '3,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } +[[ $EUID -eq 0 ]] || { echo "Voer dit script uit met sudo." >&2; exit 1; } + +UNITS=(voxip-kiosk-healthcheck.timer voxip-kiosk-healthcheck.service voxip-kiosk-agent.service + voxip-kiosk-browser.service voxip-kiosk-x.service voxip-kiosk-manager.service + voxip-network-guard.service voxip-boot-import.service) + +PREVIOUS_TARGET="$(cat /var/lib/voxip-kiosk/previous-default-target 2>/dev/null || true)" + +echo "==> Services stoppen en verwijderen" +systemctl stop voxip-network-rollback.timer 2>/dev/null || true +for unit in "${UNITS[@]}"; do + systemctl disable --now "$unit" 2>/dev/null || true + rm -f "/etc/systemd/system/$unit" +done +systemctl daemon-reload + +echo "==> Bestanden verwijderen" +rm -rf /usr/lib/voxip-kiosk /usr/share/voxip-kiosk /usr/share/doc/voxip-kiosk +rm -f /usr/local/sbin/voxip-kiosk-ctl +rm -f /etc/sudoers.d/voxip-kiosk +rm -f /etc/systemd/journald.conf.d/voxip-kiosk.conf /etc/systemd/system.conf.d/voxip-watchdog.conf +rm -f /etc/logrotate.d/voxip-kiosk /etc/avahi/services/voxip-kiosk.service +rm -f /etc/chromium/policies/managed/voxip-kiosk.json /etc/chromium-browser/policies/managed/voxip-kiosk.json +rm -f /etc/systemd/zram-generator.conf +rm -f /etc/ssh/sshd_config.d/voxip-kiosk.conf +if [[ -f /etc/X11/Xwrapper.config.voxip-orig ]]; then + mv /etc/X11/Xwrapper.config.voxip-orig /etc/X11/Xwrapper.config +else + rm -f /etc/X11/Xwrapper.config +fi +if [[ -f /etc/systemd/system/display-kiosk.service.voxip-disabled ]]; then + echo " (de eerdere InfoFlow-kioskservice staat nog als display-kiosk.service.voxip-disabled)" +fi + +if [[ $PURGE -eq 1 ]]; then + echo "==> Configuratie, status, logs en gebruikers verwijderen" + rm -rf /etc/voxip-kiosk /var/lib/voxip-kiosk /var/lib/voxip-kiosk-network /var/lib/voxip-kiosk-browser /var/log/voxip-kiosk + rm -f /etc/NetworkManager/system-connections/voxip-eth0.nmconnection /etc/NetworkManager/system-connections/voxip-wlan0.nmconnection + nmcli connection reload 2>/dev/null || true + userdel -r kiosk 2>/dev/null || true + userdel voxip 2>/dev/null || true + groupdel kiosk 2>/dev/null || true + groupdel voxip 2>/dev/null || true +else + echo " Configuratie bewaard in /etc/voxip-kiosk (verwijderen met --purge)." + echo " VoxIP-netwerkprofielen (voxip-eth0/voxip-wlan0) blijven actief." +fi + +if [[ -n $PREVIOUS_TARGET ]]; then systemctl set-default "$PREVIOUS_TARGET" >/dev/null 2>&1 || true; fi +echo "==> VoxIP Kiosk OS is verwijderd. Herstart de Raspberry Pi om de kiosk te stoppen." diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..650dfa5 --- /dev/null +++ b/update.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# Update VoxIP Kiosk OS from this checkout (or an extracted release). +# +# sudo ./update.sh # git pull --ff-only, back up config, install --upgrade +# sudo ./update.sh --no-pull # install the code as it is in this directory +# +# The configuration in /etc/voxip-kiosk is backed up first and never overwritten. + +set -euo pipefail + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PULL=1 +[[ ${1:-} == "--no-pull" ]] && PULL=0 +[[ ${1:-} == "-h" || ${1:-} == "--help" ]] && { sed -n '3,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } +[[ $EUID -eq 0 ]] || { echo "Voer dit script uit met sudo." >&2; exit 1; } + +BEFORE="$(cat /usr/share/voxip-kiosk/VERSION 2>/dev/null || echo 'niet geïnstalleerd')" + +if [[ $PULL -eq 1 && -d "$SRC/.git" ]]; then + echo "==> Laatste versie ophalen" + OWNER="$(stat -c %U "$SRC")" + sudo -u "$OWNER" git -C "$SRC" pull --ff-only +fi + +if [[ -d /etc/voxip-kiosk ]]; then + BACKUP="/var/backups/voxip-kiosk-$(date +%Y%m%d-%H%M%S).tar.gz" + echo "==> Configuratie back-uppen naar $BACKUP" + install -d -m 0700 /var/backups + tar -C /etc -czf "$BACKUP" voxip-kiosk + chmod 0600 "$BACKUP" + # keep the five most recent backups + ls -1t /var/backups/voxip-kiosk-*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -f +fi + +"$SRC/install.sh" --upgrade + +echo "==> Bijgewerkt: $BEFORE → $(cat /usr/share/voxip-kiosk/VERSION)" diff --git a/www/kiosk.css b/www/kiosk.css new file mode 100644 index 0000000..4e64fa6 --- /dev/null +++ b/www/kiosk.css @@ -0,0 +1,26 @@ +/* Local pages shown on the kiosk screen itself (file://). Large type, readable from a distance. */ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; overflow: hidden; cursor: none; user-select: none; } +body { + font-family: system-ui, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + background: radial-gradient(ellipse at 30% 20%, #163a6b 0%, #0b2545 45%, #061529 100%); + color: #eaf1fb; + display: flex; align-items: center; justify-content: center; +} +.stage { text-align: center; padding: 4vh 6vw; max-width: 1400px; transition: transform 2s ease; } +.logo { width: 9vmin; height: 9vmin; min-width: 64px; min-height: 64px; margin-bottom: 3vh; opacity: .95; } +h1 { font-size: clamp(28px, 5vmin, 72px); margin: 0 0 2vh; font-weight: 700; letter-spacing: -.01em; } +p { font-size: clamp(16px, 2.6vmin, 36px); margin: 0 0 1.5vh; color: #b9cbe4; } +.device { font-size: clamp(14px, 2.2vmin, 28px); color: #7f9cc4; text-transform: uppercase; letter-spacing: .12em; margin-bottom: 2vh; } +.clock { font-size: clamp(48px, 12vmin, 180px); font-weight: 300; font-variant-numeric: tabular-nums; margin: 3vh 0 1vh; color: #fff; } +.date { font-size: clamp(18px, 3vmin, 40px); color: #b9cbe4; } +.status { display: inline-flex; align-items: center; gap: 1.2vmin; margin-top: 4vh; padding: 1.2vmin 2.4vmin; border-radius: 999px; background: rgba(255, 255, 255, .08); font-size: clamp(14px, 2.2vmin, 28px); color: #d6e2f3; } +.dot { width: 1.6vmin; height: 1.6vmin; min-width: 10px; min-height: 10px; border-radius: 50%; background: #f0a020; animation: pulse 1.6s infinite; } +.spinner { width: 7vmin; height: 7vmin; margin: 4vh auto 0; border-radius: 50%; border: .8vmin solid rgba(255, 255, 255, .15); border-top-color: #4f9cf9; animation: spin 1s linear infinite; } +.code { display: inline-block; font-family: "DejaVu Sans Mono", ui-monospace, monospace; font-size: clamp(40px, 10vmin, 150px); font-weight: 700; letter-spacing: .12em; padding: 2vh 4vw; margin: 2vh 0 3vh; border-radius: 2vmin; background: rgba(255, 255, 255, .1); border: 2px solid rgba(79, 156, 249, .6); color: #fff; } +.urls { list-style: none; padding: 0; margin: 1vh 0 3vh; } +.urls li { font-family: "DejaVu Sans Mono", ui-monospace, monospace; font-size: clamp(18px, 3.4vmin, 48px); color: #fff; margin: .8vh 0; } +.hint { font-size: clamp(14px, 2vmin, 26px); color: #7f9cc4; } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes pulse { 50% { opacity: .3; } } diff --git a/www/logo.svg b/www/logo.svg new file mode 100644 index 0000000..7ee42a8 --- /dev/null +++ b/www/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/www/offline.html b/www/offline.html new file mode 100644 index 0000000..0670f08 --- /dev/null +++ b/www/offline.html @@ -0,0 +1,56 @@ + + + + + Geen verbinding + + + +
+ +
+

De informatie is tijdelijk niet beschikbaar

+

Er is op dit moment geen verbinding met de server.

+
--:--
+
+
Automatisch opnieuw verbinden…
+
+ + + diff --git a/www/setup.html b/www/setup.html new file mode 100644 index 0000000..500f193 --- /dev/null +++ b/www/setup.html @@ -0,0 +1,36 @@ + + + + + VoxIP Kiosk — eerste configuratie + + + +
+ +

Welkom bij VoxIP Kiosk OS

+

Open de beheerinterface op een computer, tablet of telefoon in hetzelfde netwerk:

+
    +

    en voer deze setupcode in om een beheerderswachtwoord in te stellen:

    +
    —
    +

    De code is eenmalig en verdwijnt zodra het wachtwoord is ingesteld.

    +
    + + + diff --git a/www/start.html b/www/start.html new file mode 100644 index 0000000..36b3513 --- /dev/null +++ b/www/start.html @@ -0,0 +1,27 @@ + + + + + VoxIP Kiosk + + + +
    + +
    +

    Bezig met laden…

    +

    De pagina wordt geopend zodra de verbinding gereed is.

    + +
    + + +