VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web interface on port 8080 (kiosk, system and network management), a restricted root helper, network changes with automatic rollback, a healthcheck timer, install/update/uninstall scripts and a pi-gen image build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a33c4822bf
96 files changed
+7535
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
image/work/
|
||||
image/deploy/
|
||||
.dev/
|
||||
@@ -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 {} +
|
||||
@@ -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. |
|
||||
Executable
+9
@@ -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())
|
||||
Executable
+9
@@ -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())
|
||||
Executable
+9
@@ -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())
|
||||
Executable
+9
@@ -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())
|
||||
Executable
+9
@@ -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())
|
||||
Executable
+9
@@ -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())
|
||||
Executable
+9
@@ -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"
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# VoxIP Kiosk OS: hardware watchdog. A hung kernel or PID 1 reboots the Pi.
|
||||
[Manager]
|
||||
RuntimeWatchdogSec=15s
|
||||
RebootWatchdogSec=2min
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
Executable
+23
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
IMG_SUFFIX=""
|
||||
if [ "${USE_QEMU}" = "1" ]; then
|
||||
export IMG_SUFFIX="${IMG_SUFFIX}-qemu"
|
||||
fi
|
||||
Executable
+5
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""VoxIP Kiosk OS — kiosk runtime and VoxIP Kiosk Manager."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="card card-narrow">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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://<hostname>.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 %}
|
||||
@@ -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", []))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=VoxIP Kiosk healthcheck (elke minuut)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=1min
|
||||
AccuracySec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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("/"))
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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 == []
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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
@@ -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."
|
||||
@@ -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)"
|
||||
@@ -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; } }
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user