VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
CI / test-bookworm (push) Failing after 11s
CI / test-latest (push) Successful in 1m27s

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 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-04 21:38:49 +01:00
commit a33c4822bf
96 files changed
+7535

No files matched your search

+32
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/
image/work/
image/deploy/
.dev/
+40
View File
@@ -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 {} +
+221
View File
@@ -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 <url-van-deze-repository> 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/<datum>-voxip-kiosk-os-<versie>.img.xz` plus een
`.sha256` en `BUILD-INFO-<versie>.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://<hostname>.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://<hostname>.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. |
+1
View File
@@ -0,0 +1 @@
1.0.0
+9
View File
@@ -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())
+9
View File
@@ -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())
+9
View File
@@ -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())
+9
View File
@@ -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())
+9
View File
@@ -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())
+9
View File
@@ -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())
+9
View File
@@ -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"
+3
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">VoxIP Kiosk Manager op %h</name>
<service>
<type>_http._tcp</type>
<port>8080</port>
<txt-record>path=/</txt-record>
</service>
</service-group>
+19
View File
@@ -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
}
+15
View File
@@ -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
}
+11
View File
@@ -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
+11
View File
@@ -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
}
+4
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# VoxIP Kiosk OS: hardware watchdog. A hung kernel or PID 1 reboots the Pi.
[Manager]
RuntimeWatchdogSec=15s
RebootWatchdogSec=2min
+5
View File
@@ -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
+199
View File
@@ -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
```
+125
View File
@@ -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://<ip>: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.
+35
View File
@@ -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://<hostname>.local:8080).
De setupcode voor het eerste wachtwoord staat op het scherm.
Executable
+123
View File
@@ -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://<hostname>.local:8080).
# ALLOW_DIRTY=1 build from the working tree instead of the committed HEAD.
#
# Output: image/deploy/<date>-voxip-kiosk-os-<version>.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" <<EOF
VOXIP_DEFAULT_URL=$(printf %q "$VOXIP_DEFAULT_URL")
VOXIP_DEVICE_NAME=$(printf %q "$VOXIP_DEVICE_NAME")
EOF
cat > "$PAYLOAD/BUILD-INFO" <<EOF
voxip_kiosk_os=$VERSION
source_revision=$SOURCE_REV
pi_gen_ref=$PI_GEN_REF
pi_gen_commit=$PI_GEN_COMMIT
release=$RELEASE
build_date=$BUILD_DATE
EOF
# The image has no usable default password: the first user is locked and is
# only unlocked by credentials from Raspberry Pi Imager (or SSH keys placed
# on the boot partition, see README).
FIRST_USER_PASS="$(openssl rand -base64 33 | tr -d '\n/+=')"
cat > "$PIGEN/config" <<EOF
IMG_NAME=voxip-kiosk-os-$VERSION
IMG_DATE=$BUILD_DATE
RELEASE=$RELEASE
DEPLOY_COMPRESSION=xz
COMPRESSION_LEVEL=6
LOCALE_DEFAULT=nl_NL.UTF-8
KEYBOARD_KEYMAP=us
KEYBOARD_LAYOUT="English (US)"
TIMEZONE_DEFAULT=Europe/Amsterdam
WPA_COUNTRY=NL
TARGET_HOSTNAME=$TARGET_HOSTNAME
FIRST_USER_NAME=voxipadmin
FIRST_USER_PASS=$FIRST_USER_PASS
DISABLE_FIRST_BOOT_USER_RENAME=1
ENABLE_SSH=0
STAGE_LIST="stage0 stage1 stage2 stage-voxip"
EOF
echo "==> 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"
+23
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
IMG_SUFFIX=""
if [ "${USE_QEMU}" = "1" ]; then
export IMG_SUFFIX="${IMG_SUFFIX}-qemu"
fi
+5
View File
@@ -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
+10
View File
@@ -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
}
Executable
+301
View File
@@ -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#<port>8080</port>#<port>${ADMIN_PORT}</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"
+10
View File
@@ -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"
+5
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""VoxIP Kiosk OS — kiosk runtime and VoxIP Kiosk Manager."""
+275
View File
@@ -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
+243
View File
@@ -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
+126
View File
@@ -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")
+249
View File
@@ -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)
+164
View File
@@ -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)
+161
View File
@@ -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
+446
View File
@@ -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 <verb> [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 <opdracht> [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
+37
View File
@@ -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
+23
View File
@@ -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)
+69
View File
@@ -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
+181
View File
@@ -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()))
+127
View File
@@ -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
+114
View File
@@ -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"
+100
View File
@@ -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
+42
View File
@@ -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())
+201
View File
@@ -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()
+195
View File
@@ -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
+73
View File
@@ -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
+156
View File
@@ -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)
+90
View File
@@ -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
+155
View File
@@ -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; }
+206
View File
@@ -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));
})();
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="VoxIP Kiosk">
<rect x="2" y="4" width="28" height="19" rx="3" fill="#1d6fd8"/>
<rect x="5" y="7" width="22" height="13" rx="1.5" fill="#0b2545"/>
<path d="M10 10.5l3 6 3-6M18.5 10.5v6M21.5 16.5v-6h2a1.8 1.8 0 010 3.6h-2" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="13" y="23" width="6" height="3" fill="#1d6fd8"/>
<rect x="9" y="26" width="14" height="2.5" rx="1.25" fill="#1d6fd8"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1,17 @@
<fieldset class="radio-row">
<legend>IPv4-adres</legend>
<label class="check"><input type="radio" name="method" value="dhcp" {% if p.method != 'static' %}checked{% endif %}> Automatisch (DHCP)</label>
<label class="check"><input type="radio" name="method" value="static" {% if p.method == 'static' %}checked{% endif %}> Statisch</label>
</fieldset>
<div class="static-fields" data-static-fields>
<label>IP-adres met prefix
<input type="text" name="address" value="{{ p.address }}" placeholder="192.168.1.50/24" autocomplete="off" spellcheck="false">
</label>
<label>Gateway
<input type="text" name="gateway" value="{{ p.gateway }}" placeholder="192.168.1.1" autocomplete="off" spellcheck="false">
</label>
</div>
<label>DNS-servers
<input type="text" name="dns" value="{{ p.dns }}" placeholder="Leeg = via DHCP, bijv. 1.1.1.1, 9.9.9.9" autocomplete="off" spellcheck="false">
<small>Maximaal drie, gescheiden door komma's. Verplicht bij een statisch adres.</small>
</label>
@@ -0,0 +1,19 @@
{% macro badge(status, label, field=None) -%}
<span class="badge badge-{{ status }}" {% if field %}data-badge="{{ field }}"{% endif %}><span class="dot" aria-hidden="true"></span><span data-badge-label>{{ label }}</span></span>
{%- endmacro %}
{% macro action(url, label, cls="btn-primary", confirm=None, confirm_title=None, big=False) -%}
<form method="post" action="{{ url }}" class="inline-form" {% if confirm %}data-confirm="{{ confirm }}" data-confirm-title="{{ confirm_title or label }}"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn {{ cls }}{% if big %} btn-big{% endif %}">{{ label }}</button>
</form>
{%- endmacro %}
{% macro csrf() -%}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{%- endmacro %}
{# Width is applied by app.js: the CSP does not allow inline style attributes. #}
{% macro meter(percent, field) -%}
<div class="meter" role="presentation"><span data-meter="{{ field }}" data-pct="{{ percent }}"></span></div>
{%- endmacro %}
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}{{ title or "Beheer" }}{% endblock %} · VoxIP Kiosk Manager</title>
<link rel="icon" href="{{ url_for('static', filename='logo.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}?v={{ version }}">
<script src="{{ url_for('static', filename='app.js') }}?v={{ version }}" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{{ url_for('ui.dashboard') }}">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="" width="28" height="28">
<span><strong>VoxIP</strong> Kiosk Manager</span>
</a>
{% if logged_in %}
<button class="nav-toggle" type="button" aria-controls="mainnav" aria-expanded="false" data-nav-toggle>
<span class="sr-only">Menu</span><span class="bars" aria-hidden="true"></span>
</button>
<nav id="mainnav" class="mainnav">
<a href="{{ url_for('ui.dashboard') }}" {% if active == 'dashboard' %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{{ url_for('ui.kiosk') }}" {% if active == 'kiosk' %}aria-current="page"{% endif %}>Kiosk</a>
<a href="{{ url_for('ui.system') }}" {% if active == 'system' %}aria-current="page"{% endif %}>Systeem</a>
<a href="{{ url_for('ui.network') }}" {% if active == 'network' %}aria-current="page"{% endif %}>Netwerk</a>
<a href="{{ url_for('ui.logs') }}" {% if active == 'logs' %}aria-current="page"{% endif %}>Logboek</a>
<a href="{{ url_for('ui.change_password') }}" {% if active == 'account' %}aria-current="page"{% endif %}>Wachtwoord</a>
<form method="post" action="{{ url_for('ui.logout') }}" class="nav-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Uitloggen</button>
</form>
</nav>
{% endif %}
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}" role="status">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>{{ device_name }} · {{ hostname }}</span>
<span>VoxIP Kiosk OS {{ version }}</span>
</footer>
<dialog id="confirm-dialog" class="dialog">
<form method="dialog">
<h2 data-confirm-title>Weet je het zeker?</h2>
<p data-confirm-text></p>
<div class="dialog-actions">
<button value="cancel" class="btn btn-secondary">Annuleren</button>
<button value="ok" class="btn btn-danger" data-confirm-ok>Doorgaan</button>
</div>
</form>
</dialog>
</body>
</html>
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, action, meter with context %}
{% set title = "Dashboard" %}
{% block content %}
<div class="page-head">
<h1>Dashboard</h1>
<p class="muted">Ververst automatisch elke 5 seconden.</p>
</div>
<section class="actions-row" aria-label="Snelle acties">
{{ 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) }}
</section>
<div class="grid" data-live-status="{{ url_for('ui.api_status') }}">
<section class="card">
<h2>Kiosk</h2>
<dl class="kv">
<dt>Chromium</dt><dd>{{ badge(s.browser.status, s.browser.label, "browser") }}</dd>
<dt>Kiosk-URL</dt><dd class="break" data-field="url">{{ s.url }}</dd>
<dt>Verbinding</dt><dd>{{ badge(s.network.status, s.network.label, "network") }}</dd>
<dt>Herstarts</dt><dd data-field="browser.restarts">{{ s.browser.restarts }}</dd>
</dl>
{% if s.last_error %}
<p class="note">Laatste melding: <span data-field="last_error.message">{{ s.last_error.message }}</span></p>
{% endif %}
</section>
<section class="card">
<h2>Systeem</h2>
<dl class="kv">
<dt>Uptime</dt><dd data-field="uptime">{{ s.uptime }}</dd>
<dt>CPU-belasting</dt><dd><span data-field="cpu">{{ s.cpu }}</span> <span class="muted">(load <span data-field="load">{{ s.load }}</span>)</span></dd>
<dt>Temperatuur</dt><dd>{{ badge(s.temperature_status, s.temperature, "temperature") }}</dd>
{% if s.power %}<dt>Voeding</dt><dd>{{ badge(s.power.status, s.power.label, "power") }}</dd>{% endif %}
</dl>
</section>
<section class="card">
<h2>Geheugen &amp; opslag</h2>
<dl class="kv">
<dt>RAM</dt><dd><span data-field="memory">{{ s.memory }}</span>{{ meter(s.memory_percent, "memory_percent") }}</dd>
<dt>SD-kaart</dt><dd><span data-field="disk">{{ s.disk }}</span>{{ meter(s.disk_percent, "disk_percent") }}</dd>
</dl>
</section>
<section class="card">
<h2>Netwerk</h2>
<dl class="kv">
<dt>Ethernet</dt><dd data-field="eth0">{{ s.eth0 }}</dd>
<dt>Wifi</dt><dd data-field="wlan0">{{ s.wlan0 }}</dd>
<dt>Hostname</dt><dd>{{ hostname }}.local</dd>
</dl>
</section>
<section class="card">
<h2>Over dit apparaat</h2>
<dl class="kv">
<dt>Naam</dt><dd>{{ device_name }}</dd>
<dt>Versie</dt><dd>VoxIP Kiosk OS {{ s.version }}</dd>
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
<dt>Hardware</dt><dd>{{ s.model }}</dd>
</dl>
</section>
</div>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="card card-narrow">
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</section>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, action, csrf with context %}
{% set title = "Kiosk" %}
{% block content %}
<div class="page-head">
<h1>Kioskbeheer</h1>
{{ badge(s.browser.status, s.browser.label) }}
</div>
<section class="actions-row">
{{ 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) }}
</section>
<div class="grid grid-2">
<section class="card">
<h2>Instellingen</h2>
<form method="post" action="{{ url_for('ui.kiosk_settings') }}" class="form">
{{ csrf() }}
<label>Kiosk-URL
<input type="url" name="url" value="{{ conf.url }}" required pattern="https://.+" maxlength="2048" autocomplete="off" spellcheck="false">
<small>Alleen HTTPS. Een toegangstoken in de URL (zoals <code>?token=</code> van InfoFlow) wordt elders afgeschermd getoond.</small>
</label>
<label>Apparaatnaam
<input type="text" name="device_name" value="{{ conf.device_name }}" required maxlength="64">
</label>
<label>Automatisch verversen
<select name="refresh_interval">
{% set intervals = [(0, "Uit"), (60, "Elke minuut"), (300, "Elke 5 minuten"), (900, "Elk kwartier"), (1800, "Elk half uur"), (3600, "Elk uur"), (21600, "Elke 6 uur"), (86400, "Elke dag")] %}
{% set known = intervals|map(attribute=0)|list %}
{% for value, label in intervals %}<option value="{{ value }}" {% if conf.refresh_interval == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
{% if conf.refresh_interval not in known %}<option value="{{ conf.refresh_interval }}" selected>Elke {{ conf.refresh_interval }} seconden</option>{% endif %}
</select>
<small>Wordt overgeslagen zolang de URL niet bereikbaar is, zodat een offline werkende pagina blijft staan.</small>
</label>
<label>Browserprofiel
<select name="browser_profile">
<option value="persistent" {% if conf.browser_profile == 'persistent' %}selected{% endif %}>Bewaren (cookies en offline-cache blijven na herstart)</option>
<option value="ephemeral" {% if conf.browser_profile == 'ephemeral' %}selected{% endif %}>Alleen in werkgeheugen (schoon bij iedere start)</option>
</select>
</label>
<label class="check"><input type="checkbox" name="browser_restart" value="1" {% if conf.browser_restart %}checked{% endif %}> Chromium automatisch herstarten na een crash</label>
<button type="submit" class="btn btn-primary">Opslaan</button>
</form>
</section>
<section class="card">
<h2>Huidig scherm</h2>
<div class="screenshot" data-screenshot="{{ url_for('ui.kiosk_screenshot') }}">
<p class="muted" data-screenshot-msg>Klik op ‘Screenshot maken’ om te zien wat de kiosk nu toont.</p>
<img alt="Screenshot van het kioskscherm" hidden>
</div>
<button type="button" class="btn btn-secondary" data-screenshot-btn>Screenshot maken</button>
<dl class="kv kv-tight">
<dt>Ingestelde URL</dt><dd class="break">{{ masked_url }}</dd>
<dt>Getoonde pagina</dt><dd class="break">{{ agent.page_url or "–" }}</dd>
<dt>Laatste controle</dt><dd>{% if agent.checked_at %}<time data-epoch="{{ agent.checked_at }}"></time> — {% if agent.target_reachable %}bereikbaar{% else %}{{ agent.reason or "niet bereikbaar" }}{% endif %}{% else %}–{% endif %}</dd>
{% if agent.offline_since %}<dt>Offline sinds</dt><dd><time data-epoch="{{ agent.offline_since }}"></time></dd>{% endif %}
</dl>
</section>
</div>
<section class="card">
<h2>Laatste meldingen</h2>
{% if errors %}
<pre class="log log-short">{% for line in errors %}{{ line }}
{% endfor %}</pre>
{% else %}
<p class="muted">Geen waarschuwingen of fouten.</p>
{% endif %}
<p><a href="{{ url_for('ui.logs', source='kiosk') }}">Alle kiosklogs bekijken →</a></p>
</section>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% set title = "Inloggen" %}
{% block content %}
<section class="card card-narrow">
<h1>Inloggen</h1>
<p class="muted">{{ device_name }} · {{ hostname }}</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Beheerderswachtwoord
<input type="password" name="password" required autofocus autocomplete="current-password" maxlength="128">
</label>
<button type="submit" class="btn btn-primary btn-block">Inloggen</button>
</form>
<p class="muted small">Wachtwoord vergeten? Log via SSH in en voer <code>sudo voxip-kiosk-ctl reset-password</code> uit.</p>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% set title = "Logboek" %}
{% block content %}
<div class="page-head"><h1>Logboek</h1></div>
<section class="card">
<form method="get" class="form form-inline">
<label>Bron
<select name="source">
{% for key, item in sources.items() %}<option value="{{ key }}" {% if key == source %}selected{% endif %}>{{ item[0] }}</option>{% endfor %}
</select>
</label>
<label>Regels
<select name="lines">
{% for n in line_options %}<option value="{{ n }}" {% if n == lines %}selected{% endif %}>{{ n }}</option>{% endfor %}
</select>
</label>
<label>Niveau
<select name="priority">
<option value="" {% if not priority %}selected{% endif %}>Alles</option>
<option value="warning" {% if priority == 'warning' %}selected{% endif %}>Waarschuwingen en fouten</option>
<option value="err" {% if priority == 'err' %}selected{% endif %}>Alleen fouten</option>
</select>
</label>
<button type="submit" class="btn btn-primary">Tonen</button>
</form>
<pre class="log" data-scroll-bottom>{{ text }}</pre>
</section>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% set title = "Beheerinterface herstart" %}
{% block content %}
<section class="card card-narrow" data-redirect="{{ target }}" data-redirect-after="8">
<h1>Beheerinterface herstart</h1>
<p>De beheerinterface is straks bereikbaar op:</p>
<p><a href="{{ target }}"><code>{{ target }}</code></a></p>
<p class="muted">Je wordt over enkele seconden automatisch doorgestuurd. Bij HTTPS met een zelfondertekend
certificaat vraagt je browser eenmalig om bevestiging.</p>
</section>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, csrf with context %}
{% set title = "Netwerk" %}
{% block content %}
<div class="page-head"><h1>Netwerkbeheer</h1></div>
{% if pending %}
<section class="card card-pending" data-pending="{{ url_for('ui.api_network_pending') }}" data-remaining="{{ pending.remaining }}">
<h2>Netwerkwijziging bevestigen</h2>
<p>De nieuwe instellingen voor <strong>{{ 'ethernet' if pending.interface == 'eth0' else 'wifi' }}</strong>
{% if pending.method == 'static' %}(statisch adres <strong>{{ pending.address }}</strong>){% else %}(DHCP){% endif %} zijn actief.
Werkt de beheerinterface nog? Bevestig dan. Zonder bevestiging worden over
<strong data-countdown>{{ pending.remaining }}</strong> seconden automatisch de vorige instellingen teruggezet.</p>
{% if pending.method == 'static' %}
<p class="muted">Is het adres veranderd? Open dan <code>{{ admin_scheme }}://{{ pending.address.split('/')[0] }}:{{ admin_port }}/network</code>, log in en bevestig daar.</p>
{% endif %}
<div class="actions-row">
<form method="post" action="{{ url_for('ui.network_confirm') }}" class="inline-form">{{ csrf() }}<button class="btn btn-success btn-big">Bevestigen</button></form>
<form method="post" action="{{ url_for('ui.network_rollback') }}" class="inline-form">{{ csrf() }}<button class="btn btn-secondary btn-big">Nu terugdraaien</button></form>
</div>
</section>
{% endif %}
{% if not net.available %}
<div class="alert alert-error">NetworkManager (nmcli) is niet beschikbaar op dit systeem.</div>
{% endif %}
<div class="grid grid-2">
{% for iface in ['eth0', 'wlan0'] %}
{% set d = net.devices[iface] %}
<section class="card">
<h2>{{ 'Ethernet' if iface == 'eth0' else 'Wifi' }} <span class="muted small">({{ iface }})</span></h2>
<dl class="kv">
<dt>Status</dt><dd>{{ badge('ok' if d.connected else ('starting' if d.state == 'connecting' else 'stopped'), d.state_label) }}</dd>
{% if d.connection %}<dt>Verbinding</dt><dd>{{ d.connection }}</dd>{% endif %}
<dt>IP-adres</dt><dd>{{ d.addresses|join(', ') or '–' }}</dd>
<dt>Gateway</dt><dd>{{ d.gateway or '–' }}</dd>
<dt>DNS</dt><dd>{{ d.dns|join(', ') or '–' }}</dd>
{% if d.hwaddr %}<dt>MAC</dt><dd><code>{{ d.hwaddr }}</code></dd>{% endif %}
</dl>
</section>
{% endfor %}
</div>
<div class="grid grid-2">
<section class="card">
<h2>Ethernet instellen</h2>
{% set p = net.devices.eth0.profile %}
<form method="post" action="{{ url_for('ui.network_apply') }}" class="form" data-ipform
data-confirm="De netwerkinstellingen worden gewijzigd. Bevestig daarna binnen {{ rollback_seconds // 60 }} minuten, anders worden ze teruggezet." data-confirm-title="Ethernet wijzigen">
{{ csrf() }}
<input type="hidden" name="interface" value="eth0">
{% include "_ipv4_fields.html" %}
<button type="submit" class="btn btn-primary">Toepassen</button>
</form>
</section>
<section class="card">
<h2>Wifi instellen</h2>
{% set p = net.devices.wlan0.profile %}
<form method="post" action="{{ url_for('ui.network_apply') }}" class="form" data-ipform
data-confirm="De wifi-instellingen worden gewijzigd. Bevestig daarna binnen {{ rollback_seconds // 60 }} minuten, anders worden ze teruggezet." data-confirm-title="Wifi wijzigen">
{{ csrf() }}
<input type="hidden" name="interface" value="wlan0">
<label>Netwerknaam (SSID)
<span class="input-row">
<input type="text" name="ssid" value="{{ p.ssid }}" required maxlength="32" list="wifi-networks" autocomplete="off" spellcheck="false">
<button type="button" class="btn btn-secondary" data-wifi-scan="{{ url_for('ui.network_scan') }}">Scannen</button>
</span>
<datalist id="wifi-networks"></datalist>
</label>
<ul class="scan-results" data-scan-results hidden></ul>
<label>Wachtwoord
<input type="password" name="psk" minlength="8" maxlength="64" autocomplete="new-password" {% if p.exists %}placeholder="Leeg laten om het huidige wachtwoord te behouden"{% endif %}>
</label>
<label class="check"><input type="checkbox" name="open" value="1"> Open netwerk (zonder wachtwoord)</label>
<label class="check"><input type="checkbox" name="hidden" value="1" {% if p.hidden %}checked{% endif %}> Verborgen netwerk</label>
{% include "_ipv4_fields.html" %}
<button type="submit" class="btn btn-primary">Toepassen</button>
</form>
<h2 class="mt">Wifi-land</h2>
<form method="post" action="{{ url_for('ui.network_country') }}" class="form form-inline">
{{ csrf() }}
<label>Landcode
<input type="text" name="country" value="{{ net.wifi_country or 'NL' }}" required maxlength="2" pattern="[A-Za-z]{2}" class="input-short">
</label>
<button type="submit" class="btn btn-secondary">Instellen</button>
</form>
</section>
</div>
<section class="card">
<h2>Herstel bij een onbereikbare kiosk</h2>
<ul class="plain">
<li>Iedere wijziging wordt na {{ rollback_seconds // 60 }} minuten automatisch teruggedraaid als je hem niet bevestigt, ook als de stroom tussendoor uitvalt.</li>
<li>Noodherstel: zet een leeg bestand <code>voxip-network-reset</code> op de bootpartitie van de SD-kaart en start de Pi. Alle VoxIP-netwerkprofielen worden dan verwijderd (terug naar DHCP).</li>
</ul>
</section>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% set title = "Wachtwoord wijzigen" %}
{% block content %}
<section class="card card-narrow">
<h1>Wachtwoord wijzigen</h1>
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Huidig wachtwoord
<input type="password" name="current_password" required autocomplete="current-password" maxlength="128">
</label>
<label>Nieuw wachtwoord
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<label>Herhaal nieuw wachtwoord
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Wijzigen</button>
</form>
<p class="muted small">Na het wijzigen worden alle andere sessies uitgelogd.</p>
</section>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% set title = "Eerste configuratie" %}
{% block content %}
<section class="card card-narrow">
<h1>Welkom bij VoxIP Kiosk Manager</h1>
<p>Stel eerst een uniek beheerderswachtwoord in. Je hebt daarvoor de <strong>setupcode</strong> nodig die op het
scherm van de kiosk staat (of via SSH: <code>sudo voxip-kiosk-ctl setup-code</code>).</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Setupcode
<input type="text" name="setup_code" required autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="ABCD-EFGH" maxlength="12" class="input-code">
</label>
<label>Nieuw wachtwoord
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
<small>Minimaal 10 tekens. Gebruik voor iedere kiosk een eigen wachtwoord.</small>
</label>
<label>Herhaal wachtwoord
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Wachtwoord instellen</button>
</form>
</section>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% from "_macros.html" import action, csrf with context %}
{% set title = "Systeem" %}
{% block content %}
<div class="page-head"><h1>Systeembeheer</h1></div>
<section class="actions-row">
{{ 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) }}
</section>
<div class="grid grid-2">
<section class="card">
<h2>Scherm</h2>
<form method="post" action="{{ url_for('ui.system_display') }}" class="form">
{{ csrf() }}
<label>Rotatie
<select name="rotation">
{% for deg, label in [(0, "0° (normaal)"), (90, "90° (rechtsom)"), (180, "180° (ondersteboven)"), (270, "270° (linksom)")] %}
<option value="{{ deg }}" {% if conf.rotation == deg %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Resolutie
<select name="resolution">
<option value="auto" {% if conf.resolution == 'auto' %}selected{% endif %}>Automatisch (voorkeur van het scherm)</option>
{% for mode in modes %}<option value="{{ mode }}" {% if conf.resolution == mode %}selected{% endif %}>{{ mode }}</option>{% endfor %}
{% if conf.resolution != 'auto' and conf.resolution not in modes %}<option value="{{ conf.resolution }}" selected>{{ conf.resolution }}</option>{% endif %}
</select>
<small>{% if output %}Aangesloten: {{ output.name }}{% if output.current %}, nu {{ output.current }}{% endif %}.{% else %}Schermgegevens verschijnen zodra de kiosk draait.{% endif %}</small>
</label>
<label>Zoomfactor
<input type="number" name="zoom" min="0.25" max="4" step="0.05" value="{{ conf.zoom }}">
<small>1 = normaal. Groter maakt alles groter (handig op grote tv's).</small>
</label>
<label class="check"><input type="checkbox" name="screen_always_on" value="1" {% if conf.screen_always_on %}checked{% endif %} data-toggle-target="#screen-off"> Scherm altijd aan (geen screensaver of energiebeheer)</label>
<label id="screen-off">Scherm uitschakelen na (minuten zonder invoer)
<input type="number" name="screen_off_minutes" min="1" max="240" value="{{ conf.screen_off_minutes }}">
</label>
<label class="check"><input type="checkbox" name="apply_now" value="1" checked> Direct toepassen (Chromium herstart)</label>
<button type="submit" class="btn btn-primary">Opslaan</button>
</form>
</section>
<section class="card">
<h2>Hostname</h2>
<form method="post" action="{{ url_for('ui.system_hostname') }}" class="form">
{{ csrf() }}
<label>Hostname
<input type="text" name="hostname" value="{{ hostname }}" required maxlength="63" pattern="[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?" autocomplete="off" spellcheck="false">
<small>De beheerinterface is daarna bereikbaar via <code>http://&lt;hostname&gt;.local:{{ conf.admin_port }}</code>. Geef iedere kiosk op hetzelfde netwerk een unieke naam.</small>
</label>
<button type="submit" class="btn btn-primary">Hostname wijzigen</button>
</form>
<h2 class="mt">Beheerinterface</h2>
<form method="post" action="{{ url_for('ui.system_admin') }}" class="form" data-confirm="Als je poort of HTTPS wijzigt, herstart de beheerinterface en word je doorgestuurd naar het nieuwe adres." data-confirm-title="Beheerinterface aanpassen">
{{ csrf() }}
<label>Poort
<input type="number" name="admin_port" min="1024" max="65535" value="{{ conf.admin_port }}">
</label>
<label class="check"><input type="checkbox" name="admin_https" value="1" {% if conf.admin_https %}checked{% endif %}> HTTPS gebruiken (zelfondertekend certificaat)</label>
<label class="check"><input type="checkbox" name="admin_allow_public" value="1" {% if conf.admin_allow_public %}checked{% endif %}> Toegang vanaf publieke IP-adressen toestaan <span class="muted">(afgeraden; gebruik liever VPN)</span></label>
<button type="submit" class="btn btn-secondary">Opslaan</button>
</form>
</section>
</div>
<section class="card">
<h2>Systeeminformatie</h2>
<dl class="kv">
<dt>Versie</dt><dd>VoxIP Kiosk OS {{ s.version }}</dd>
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
<dt>Hardware</dt><dd>{{ s.model }}</dd>
<dt>Uptime</dt><dd>{{ s.uptime }}</dd>
</dl>
<p><a href="{{ url_for('ui.logs', source='system') }}">Systeemlogs bekijken →</a></p>
</section>
{% endblock %}
+551
View File
@@ -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", []))
+17
View File
@@ -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
+28
View File
@@ -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
+36
View File
@@ -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
+11
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=VoxIP Kiosk healthcheck (elke minuut)
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
AccuracySec=10s
[Install]
WantedBy=timers.target
+31
View File
@@ -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
+31
View File
@@ -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
+16
View File
@@ -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
View File
Whitespace-only changes.
+142
View File
@@ -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("/"))
+171
View File
@@ -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"
+80
View File
@@ -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()
+95
View File
@@ -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})
+85
View File
@@ -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 == []
+201
View File
@@ -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()
+79
View File
@@ -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"
+46
View File
@@ -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"}
+95
View File
@@ -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")
+300
View File
@@ -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
Executable
+65
View File
@@ -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."
Executable
+38
View File
@@ -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)"
+26
View File
@@ -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; } }
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="VoxIP Kiosk">
<rect x="2" y="4" width="28" height="19" rx="3" fill="#1d6fd8"/>
<rect x="5" y="7" width="22" height="13" rx="1.5" fill="#0b2545"/>
<path d="M10 10.5l3 6 3-6M18.5 10.5v6M21.5 16.5v-6h2a1.8 1.8 0 010 3.6h-2" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="13" y="23" width="6" height="3" fill="#1d6fd8"/>
<rect x="9" y="26" width="14" height="2.5" rx="1.25" fill="#1d6fd8"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Geen verbinding</title>
<link rel="stylesheet" href="kiosk.css">
</head>
<body>
<main class="stage" id="stage">
<img class="logo" src="logo.svg" alt="">
<div class="device" id="device"></div>
<h1>De informatie is tijdelijk niet beschikbaar</h1>
<p id="detail">Er is op dit moment geen verbinding met de server.</p>
<div class="clock" id="clock">--:--</div>
<div class="date" id="date"></div>
<div class="status"><span class="dot" aria-hidden="true"></span><span id="status">Automatisch opnieuw verbinden…</span></div>
</main>
<script>
const params = new URLSearchParams(location.search);
const fmtTime = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" });
const fmtDate = new Intl.DateTimeFormat("nl-NL", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
document.getElementById("device").textContent = params.get("name") || "";
const host = params.get("host");
const reason = params.get("reason");
if (host) {
document.getElementById("detail").textContent =
"Geen verbinding met " + host + (reason ? " (" + reason + ")" : "") + ".";
}
const since = Number(params.get("since")) * 1000;
function tick() {
const now = new Date();
document.getElementById("clock").textContent = fmtTime.format(now);
const date = fmtDate.format(now);
document.getElementById("date").textContent = date.charAt(0).toUpperCase() + date.slice(1);
if (since) {
const minutes = Math.floor((now - since) / 60000);
document.getElementById("status").textContent = minutes >= 1
? "Offline sinds " + fmtTime.format(new Date(since)) + " — automatisch opnieuw verbinden…"
: "Automatisch opnieuw verbinden…";
}
}
tick();
setInterval(tick, 1000);
// Shift the content slowly to avoid burn-in on screens that stay offline for long.
let step = 0;
setInterval(() => {
step = (step + 1) % 8;
const dx = [0, 12, 24, 12, 0, -12, -24, -12][step];
const dy = [0, -8, 0, 8, 0, -8, 0, 8][step];
document.getElementById("stage").style.transform = "translate(" + dx + "px," + dy + "px)";
}, 60000);
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>VoxIP Kiosk — eerste configuratie</title>
<link rel="stylesheet" href="kiosk.css">
</head>
<body>
<main class="stage">
<img class="logo" src="logo.svg" alt="">
<h1>Welkom bij VoxIP Kiosk OS</h1>
<p>Open de beheerinterface op een computer, tablet of telefoon in hetzelfde netwerk:</p>
<ul class="urls" id="urls"></ul>
<p>en voer deze setupcode in om een beheerderswachtwoord in te stellen:</p>
<div class="code" id="code">—</div>
<p class="hint">De code is eenmalig en verdwijnt zodra het wachtwoord is ingesteld.</p>
</main>
<script>
const params = new URLSearchParams(location.search);
const scheme = params.get("scheme") === "https" ? "https" : "http";
const port = params.get("port") || "8080";
const suffix = (scheme === "http" && port === "80") || (scheme === "https" && port === "443") ? "" : ":" + port;
const urls = [];
if (params.get("host")) urls.push(scheme + "://" + params.get("host") + ".local" + suffix);
(params.get("ips") || "").split(",").filter(Boolean).forEach((ip) => urls.push(scheme + "://" + ip + suffix));
const list = document.getElementById("urls");
if (!urls.length) urls.push("Nog geen netwerkverbinding — sluit een netwerkkabel aan");
urls.forEach((u) => {
const li = document.createElement("li");
li.textContent = u;
list.appendChild(li);
});
document.getElementById("code").textContent = params.get("code") || "zie: sudo voxip-kiosk-ctl setup-code";
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>VoxIP Kiosk</title>
<link rel="stylesheet" href="kiosk.css">
</head>
<body>
<main class="stage">
<img class="logo" src="logo.svg" alt="">
<div class="device" id="device"></div>
<h1>Bezig met laden…</h1>
<p>De pagina wordt geopend zodra de verbinding gereed is.</p>
<div class="spinner" aria-hidden="true"></div>
</main>
<script>
// The kiosk agent normally navigates away from this page within seconds.
// If the agent is not running, open the configured URL ourselves.
const params = new URLSearchParams(location.search);
document.getElementById("device").textContent = params.get("name") || "";
const next = params.get("next") || "";
if (next.startsWith("https://")) {
setTimeout(() => location.replace(next), 45000);
}
</script>
</body>
</html>