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

Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web
interface on port 8080 (kiosk, system and network management), a
restricted root helper, network changes with automatic rollback, a
healthcheck timer, install/update/uninstall scripts and a pi-gen image
build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-04 21:38:49 +01:00
commit a33c4822bf
96 files changed
+7535

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""VoxIP Kiosk OS — kiosk runtime and VoxIP Kiosk Manager."""
+275
View File
@@ -0,0 +1,275 @@
"""voxip-kiosk-agent — keeps the kiosk page where it should be.
Runs as user ``kiosk`` and steers Chromium through the DevTools Protocol on
127.0.0.1:
* shows the setup page (with setup code) until an admin password exists;
* leaves the local start page / Chromium error page for the kiosk URL as
soon as that URL is reachable, and shows the offline page meanwhile;
* follows URL changes from the web interface without restarting Chromium;
* reloads periodically when ``refresh_interval`` is set — but never while
the URL is unreachable, so a page that works offline keeps working;
* reloads a hung renderer, and closes the browser (systemd restarts it) if
that does not help.
"""
from __future__ import annotations
import json
import logging
import signal
import socket
import ssl
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlencode, urlsplit
from . import logutil, sysinfo
from .cdp import CDP, CDPError
from .config import load_config
from .paths import Paths, load_paths
from .validation import mask_url
log = logging.getLogger("voxip-kiosk-agent")
TICK_SECONDS = 5
PROBE_MAX_AGE_LOADING = 10
PROBE_MAX_AGE_ONLINE = 60
HUNG_RELOAD_AFTER = 3
HUNG_RESTART_AFTER = 6
BLANK_URLS = {"", "about:blank", "chrome://newtab/", "chrome://new-tab-page/"}
def probe(url: str, timeout: float = 8.0) -> tuple[bool, str]:
"""Is ``url`` reachable over HTTPS with a valid certificate? → (ok, reason)."""
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "VoxIP-Kiosk-OS/agent"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl.create_default_context()):
return True, ""
except urllib.error.HTTPError as exc:
if exc.code >= 500:
return False, f"Serverfout {exc.code}"
return True, ""
except urllib.error.URLError as exc:
return False, _reason(exc.reason)
except (OSError, ValueError) as exc:
return False, _reason(exc)
def _reason(exc: Any) -> str:
if isinstance(exc, ssl.SSLCertVerificationError) or "CERTIFICATE_VERIFY_FAILED" in str(exc):
return "Certificaat ongeldig"
if isinstance(exc, ssl.SSLError):
return "TLS-fout"
if isinstance(exc, socket.gaierror):
return "Naam niet gevonden (DNS)"
if isinstance(exc, (socket.timeout, TimeoutError)):
return "Time-out"
if isinstance(exc, ConnectionRefusedError):
return "Verbinding geweigerd"
if isinstance(exc, OSError) and exc.errno in (101, 113):
return "Geen netwerkverbinding"
return "Niet bereikbaar"
class Agent:
def __init__(self, paths: Paths, cdp: Any = None, probe_fn: Callable[[str], tuple[bool, str]] = probe,
clock: Callable[[], float] = time.monotonic, wall: Callable[[], float] = time.time,
hostname: Callable[[], str] = lambda: socket.gethostname().split(".")[0], addresses: Callable[[], dict] = sysinfo.ip_addresses):
self.paths = paths
self.cdp = cdp or CDP()
self.probe_fn = probe_fn
self.clock = clock
self.wall = wall
self.hostname = hostname
self.addresses = addresses
self.applied_url: str | None = None
self.last_load: float | None = None
self.hung = 0
self.offline_since: float | None = None
self.refresh_skipped_logged = False
self._probe: tuple[str, float, bool, str] | None = None
self.state: dict[str, Any] = {"last_error": None}
self._written = ""
self._setup_shown: str | None = None
# -- helpers ------------------------------------------------------------
def local_url(self, page: str, params: dict[str, Any]) -> str:
return f"file://{self.paths.www_dir}/{page}.html?" + urlencode(params)
def classify(self, url: str) -> str:
prefix = f"file://{self.paths.www_dir}/"
if url.startswith(prefix):
return url[len(prefix):].split(".html", 1)[0] # start / offline / setup
if url.startswith("chrome-error://"):
return "error"
if url in BLANK_URLS:
return "blank"
return "remote"
def reachable(self, url: str, max_age: float) -> tuple[bool, str]:
now = self.clock()
if self._probe and self._probe[0] == url and now - self._probe[1] < max_age:
return self._probe[2], self._probe[3]
ok, reason = self.probe_fn(url)
self._probe = (url, now, ok, reason)
self.state.update(target_reachable=ok, reason=reason, checked_at=int(self.wall()))
return ok, reason
def error(self, message: str) -> None:
log.warning(message)
self.state["last_error"] = {"at": int(self.wall()), "message": message}
def setup_required(self) -> bool:
return self.paths.setup_code_file.exists() and not self.paths.admin_file.exists()
def setup_url(self, config: dict[str, Any]) -> str:
try:
code = self.paths.setup_code_file.read_text(encoding="utf-8").strip()
except OSError:
code = ""
ips = sorted({a.split("/")[0] for addrs in self.addresses().values() for a in addrs if ":" not in a})
scheme = "https" if config["admin_https"] else "http"
return self.local_url("setup", {"code": code, "host": self.hostname(), "ips": ",".join(ips),
"port": config["admin_port"], "scheme": scheme, "name": config["device_name"]})
def offline_url(self, config: dict[str, Any], reason: str) -> str:
return self.local_url("offline", {"name": config["device_name"], "host": urlsplit(config["url"]).hostname or "",
"reason": reason, "since": int(self.offline_since or self.wall())})
def navigate(self, url: str) -> None:
self.cdp.navigate(url)
# -- main loop ----------------------------------------------------------
def tick(self) -> None:
config = load_config(self.paths.config_file)
target = config["url"]
self.state["updated"] = int(self.wall())
try:
page = self.cdp.main_page()
if page is None:
self.cdp.new_page(self.local_url("start", {"name": config["device_name"], "next": target}))
return
self._tick_page(config, target, page)
self.state["browser"] = "ok"
except CDPError as exc:
self.state.update(browser="unreachable", page=None)
self.hung = 0
log.debug("DevTools niet bereikbaar: %s", exc)
finally:
self.write_state()
def _tick_page(self, config: dict[str, Any], target: str, page: dict[str, Any]) -> None:
url = page.get("url", "")
kind = self.classify(url)
self.state.update(page=kind, page_url=mask_url(url) if kind == "remote" else kind)
if self.setup_required():
self.state["setup_required"] = True
want = self.setup_url(config)
if kind != "setup" or want != self._setup_shown:
self.navigate(want)
self._setup_shown = want
return
self.state["setup_required"] = False
self._setup_shown = None
url_changed = self.applied_url is not None and target != self.applied_url
if url_changed:
log.info("Nieuwe kiosk-URL: %s", mask_url(target))
self.applied_url = target
if kind != "remote" or url_changed:
ok, reason = self.reachable(target, 0 if url_changed else PROBE_MAX_AGE_LOADING)
if ok:
if self.offline_since is not None:
log.info("Kiosk-URL weer bereikbaar na %.0f s", self.wall() - self.offline_since)
self.navigate(target)
self.last_load = self.clock()
self.offline_since = None
self.state["offline_since"] = None
elif kind != "offline" or url_changed:
if self.offline_since is None:
self.offline_since = self.wall()
self.state["offline_since"] = int(self.offline_since)
self.error(f"Kiosk-URL niet bereikbaar ({reason}); offlinepagina getoond")
self.navigate(self.offline_url(config, reason))
self.hung = 0
return
if self.last_load is None:
self.last_load = self.clock()
if not self.cdp.responsive(page.get("webSocketDebuggerUrl")):
self.hung += 1
if self.hung == HUNG_RELOAD_AFTER:
self.error("Pagina reageert niet; herladen")
self.cdp.reload()
self.last_load = self.clock()
elif self.hung >= HUNG_RESTART_AFTER and config["browser_restart"]:
self.error("Pagina reageert nog steeds niet; Chromium wordt herstart")
self.hung = 0
self.cdp.close_browser()
return
self.hung = 0
ok, reason = self.reachable(target, PROBE_MAX_AGE_ONLINE)
interval = config["refresh_interval"]
if interval and self.clock() - self.last_load >= interval:
if ok:
log.info("Automatisch verversen (interval %s s)", interval)
self.cdp.reload()
self.last_load = self.clock()
self.refresh_skipped_logged = False
elif not self.refresh_skipped_logged:
self.error(f"Verversen overgeslagen: kiosk-URL niet bereikbaar ({reason})")
self.refresh_skipped_logged = True
def write_state(self) -> None:
state = dict(self.state, last_load_wall=int(self.wall() - (self.clock() - self.last_load)) if self.last_load else None)
payload = json.dumps(state, sort_keys=True)
if payload == self._written:
return
try:
tmp = self.paths.agent_state_file.with_suffix(".tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(self.paths.agent_state_file)
self._written = payload
except OSError as exc:
log.debug("Kon agentstatus niet schrijven: %s", exc)
def read_state(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def main() -> int:
logutil.setup()
agent = Agent(load_paths())
running = True
def stop(*_: Any) -> None:
nonlocal running
running = False
signal.signal(signal.SIGTERM, stop)
log.info("Kiosk-agent gestart")
while running:
try:
agent.tick()
except Exception: # never die on an unexpected page state; systemd would only restart us
log.exception("Onverwachte fout in de kiosk-agent")
for _ in range(TICK_SECONDS * 2):
if not running:
break
time.sleep(0.5)
return 0
+243
View File
@@ -0,0 +1,243 @@
"""voxip-kiosk-browser — prepares the display and runs Chromium.
Started by ``voxip-kiosk-browser.service`` as user ``kiosk`` once the X
server is up. Exit status tells systemd whether to restart: 1 when
``browser_restart`` is enabled (``Restart=on-failure`` restarts it and the
start limit catches crash loops), 0 when it is disabled.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from . import logutil
from .config import load_config
from .paths import Paths, load_paths
from .validation import parse_resolution
log = logging.getLogger("voxip-kiosk-browser")
CDP_PORT = 9222
CHROMIUM_CANDIDATES = ("/usr/bin/chromium-browser", "/usr/bin/chromium")
ROTATIONS = {0: "normal", 90: "right", 180: "inverted", 270: "left"}
# Every flag here is deliberate; certificate validation is never weakened.
BASE_FLAGS = [
"--kiosk",
"--noerrdialogs",
"--disable-infobars",
"--disable-session-crashed-bubble",
"--no-first-run",
"--no-default-browser-check",
"--disable-translate",
"--disable-features=Translate,TranslateUI,MediaRouter,OptimizationHints,HardwareMediaKeyHandling,GlobalMediaControls",
"--disable-component-update",
"--check-for-update-interval=31536000",
"--simulate-outdated-no-au=Tue, 31 Dec 2099 23:59:59 GMT",
"--disable-pinch",
"--overscroll-history-navigation=0",
"--autoplay-policy=no-user-gesture-required",
"--password-store=basic",
"--ozone-platform=x11",
"--window-position=0,0",
"--remote-debugging-address=127.0.0.1",
f"--remote-debugging-port={CDP_PORT}",
]
FORBIDDEN_FLAGS = ("--ignore-certificate-errors", "--ignore-ssl-errors", "--allow-insecure-localhost", "--disable-web-security", "--remote-debugging-address=0.0.0.0")
def find_chromium() -> str:
for candidate in CHROMIUM_CANDIDATES:
if os.access(candidate, os.X_OK):
return candidate
raise SystemExit("Chromium is niet geïnstalleerd")
def build_args(chromium: str, config: dict[str, Any], profile_dir: Path, cache_dir: Path, start_url: str,
window_size: tuple[int, int] | None = None) -> list[str]:
args = [chromium, *BASE_FLAGS,
f"--user-data-dir={profile_dir}",
f"--disk-cache-dir={cache_dir}",
"--disk-cache-size=33554432"]
if window_size:
args.append(f"--window-size={window_size[0]},{window_size[1]}")
zoom = float(config.get("zoom", 1.0))
if abs(zoom - 1.0) > 0.001:
args.append(f"--force-device-scale-factor={zoom:g}")
args.append(start_url)
assert not any(a.startswith(FORBIDDEN_FLAGS) for a in args)
return args
def start_page_url(paths: Paths, config: dict[str, Any]) -> str:
query = urlencode({"name": config["device_name"], "next": config["url"]})
return f"file://{paths.www_dir}/start.html?{query}"
# -- display -------------------------------------------------------------
def parse_xrandr(output: str) -> list[dict[str, Any]]:
"""Connected outputs with their modes from ``xrandr --query``."""
outputs: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for line in output.splitlines():
head = re.match(r"^(\S+) (connected|disconnected)(?: primary)?(?: (\d+)x(\d+)\+\d+\+\d+)?", line)
if head:
current = None
if head.group(2) == "connected":
current = {"name": head.group(1), "modes": [], "current": None, "preferred": None}
if head.group(3):
current["size"] = (int(head.group(3)), int(head.group(4)))
outputs.append(current)
continue
mode = re.match(r"^\s+(\d+x\d+i?)\s+(.*)$", line)
if mode and current is not None:
name = mode.group(1)
rates = re.findall(r"(\d+\.\d+)([ *+]*)", mode.group(2))
if name not in current["modes"]:
current["modes"].append(name)
for rate, marks in rates:
if "*" in marks:
current["current"] = f"{name}@{rate}"
if "+" in marks and current["preferred"] is None:
current["preferred"] = name
return outputs
def xrandr_args(output: dict[str, Any], config: dict[str, Any]) -> list[str]:
args = ["xrandr", "--output", output["name"]]
res = parse_resolution(config.get("resolution", "auto"))
if res and res[0] in output["modes"]:
args += ["--mode", res[0]]
if res[1]:
args += ["--rate", res[1]]
else:
if res:
log.warning("Resolutie %s niet ondersteund door %s; automatisch gekozen", config["resolution"], output["name"])
args.append("--auto")
args += ["--rotate", ROTATIONS.get(int(config.get("rotation", 0)), "normal")]
return args
def xset_commands(config: dict[str, Any]) -> list[list[str]]:
if config.get("screen_always_on", True):
return [["xset", "s", "off"], ["xset", "s", "noblank"], ["xset", "-dpms"]]
seconds = int(config.get("screen_off_minutes", 10)) * 60
return [["xset", "s", "off"], ["xset", "+dpms"], ["xset", "dpms", "0", "0", str(seconds)]]
def _run(args: list[str]) -> subprocess.CompletedProcess | None:
try:
return subprocess.run(args, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError) as exc:
log.warning("%s mislukt: %s", args[0], exc)
return None
def wait_for_x(timeout: float = 60) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
proc = _run(["xset", "q"])
if proc is not None and proc.returncode == 0:
return True
time.sleep(1)
return False
def prepare_display(paths: Paths, config: dict[str, Any]) -> tuple[int, int] | None:
outputs: list[dict[str, Any]] = []
# A monitor that powers up after the Pi may need a moment to report itself.
for _ in range(15):
proc = _run(["xrandr", "--query"])
outputs = parse_xrandr(proc.stdout) if proc and proc.returncode == 0 else []
if outputs:
break
time.sleep(2)
size = None
if outputs:
output = outputs[0]
proc = _run(xrandr_args(output, config))
if proc is not None and proc.returncode != 0:
log.warning("xrandr: %s", proc.stderr.strip())
refreshed = _run(["xrandr", "--query"])
if refreshed and refreshed.returncode == 0:
outputs = parse_xrandr(refreshed.stdout) or outputs
output = outputs[0]
size = output.get("size")
else:
log.warning("Geen aangesloten scherm gevonden")
for cmd in xset_commands(config):
_run(cmd)
info = {"outputs": outputs, "rotation": config.get("rotation", 0), "updated": int(time.time())}
try:
paths.display_info_file.write_text(json.dumps(info), encoding="utf-8")
except OSError as exc:
log.warning("Kon schermgegevens niet opslaan: %s", exc)
return size
# -- profile -------------------------------------------------------------
def clean_profile(profile_dir: Path) -> None:
"""Remove stale locks and the 'did not shut down correctly' flag left by a crash or power cut."""
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
try:
(profile_dir / name).unlink()
except FileNotFoundError:
pass
except OSError as exc:
log.warning("Kon %s niet verwijderen: %s", name, exc)
prefs = profile_dir / "Default" / "Preferences"
try:
data = json.loads(prefs.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
profile = data.setdefault("profile", {})
profile["exit_type"] = "Normal"
profile["exited_cleanly"] = True
tmp = prefs.with_suffix(".voxip-tmp")
tmp.write_text(json.dumps(data), encoding="utf-8")
os.replace(tmp, prefs)
def profile_dirs(config: dict[str, Any]) -> tuple[Path, Path]:
runtime = Path(os.environ.get("RUNTIME_DIRECTORY", "/run/voxip-kiosk-browser"))
state = Path(os.environ.get("STATE_DIRECTORY", "/var/lib/voxip-kiosk-browser"))
profile = state / "profile" if config.get("browser_profile") == "persistent" else runtime / "profile"
if config.get("browser_profile") != "persistent" and (state / "profile").exists():
shutil.rmtree(state / "profile", ignore_errors=True)
cache = runtime / "cache"
profile.mkdir(parents=True, exist_ok=True, mode=0o700)
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
return profile, cache
def main() -> int:
logutil.setup()
paths = load_paths()
config = load_config(paths.config_file)
if not wait_for_x():
log.error("X-server niet bereikbaar")
return 1
size = prepare_display(paths, config)
profile, cache = profile_dirs(config)
clean_profile(profile)
args = build_args(find_chromium(), config, profile, cache, start_page_url(paths, config), size)
log.info("Chromium starten (profiel: %s, zoom: %s, rotatie: %s°)", config["browser_profile"], config["zoom"], config["rotation"])
started = time.monotonic()
code = subprocess.call(args)
lifetime = time.monotonic() - started
if config.get("browser_restart", True):
log.warning("Chromium gestopt (exitcode %s na %.0f s); systemd start opnieuw", code, lifetime)
return 1
log.warning("Chromium gestopt (exitcode %s); automatisch herstarten staat uit", code)
return 0
+126
View File
@@ -0,0 +1,126 @@
"""Minimal Chrome DevTools Protocol client for the local kiosk browser.
Chromium listens on 127.0.0.1 only (``--remote-debugging-address``); this
client refuses any other host. Each call opens a short-lived WebSocket to
the page target, which keeps the client stateless and robust against
browser restarts.
"""
from __future__ import annotations
import base64
import itertools
import json
import urllib.request
from typing import Any
DEFAULT_PORT = 9222
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
class CDPError(Exception):
pass
class CDP:
def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT, timeout: float = 5.0):
if host not in _LOCAL_HOSTS:
raise ValueError("DevTools is alleen lokaal bereikbaar")
self.host = host
self.port = port
self.timeout = timeout
self._ids = itertools.count(1)
# -- HTTP endpoints -------------------------------------------------
def _http_json(self, path: str, method: str = "GET") -> Any:
req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", method=method)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except (OSError, ValueError) as exc:
raise CDPError(f"DevTools niet bereikbaar: {exc}") from exc
def version(self) -> dict[str, Any]:
return self._http_json("/json/version")
def pages(self) -> list[dict[str, Any]]:
return [t for t in self._http_json("/json/list") if t.get("type") == "page"]
def main_page(self) -> dict[str, Any] | None:
pages = self.pages()
return pages[0] if pages else None
def new_page(self, url: str) -> dict[str, Any]:
from urllib.parse import quote
return self._http_json("/json/new?" + quote(url, safe=""), method="PUT")
# -- WebSocket commands ---------------------------------------------
def call(self, ws_url: str, method: str, params: dict[str, Any] | None = None, timeout: float | None = None) -> dict[str, Any]:
try:
import websocket # websocket-client (python3-websocket)
except ImportError as exc: # pragma: no cover - dependency missing
raise CDPError("python3-websocket is niet geïnstalleerd") from exc
msg_id = next(self._ids)
try:
# Chromium rejects DevTools WebSockets that carry a foreign Origin;
# we send none at all.
ws = websocket.create_connection(ws_url, timeout=timeout or self.timeout, suppress_origin=True)
except Exception as exc:
raise CDPError(f"Verbinding met DevTools mislukt: {exc}") from exc
try:
ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
while True:
reply = json.loads(ws.recv())
if reply.get("id") == msg_id:
break
except Exception as exc:
raise CDPError(f"DevTools-opdracht {method} mislukt: {exc}") from exc
finally:
try:
ws.close()
except Exception:
pass
if "error" in reply:
raise CDPError(f"{method}: {reply['error'].get('message', reply['error'])}")
return reply.get("result", {})
def _page_ws(self) -> str:
page = self.main_page()
if not page or not page.get("webSocketDebuggerUrl"):
raise CDPError("Geen browserpagina gevonden")
return page["webSocketDebuggerUrl"]
def current_url(self) -> str | None:
page = self.main_page()
return page.get("url") if page else None
def navigate(self, url: str) -> None:
self.call(self._page_ws(), "Page.navigate", {"url": url})
def reload(self, ignore_cache: bool = False) -> None:
self.call(self._page_ws(), "Page.reload", {"ignoreCache": ignore_cache})
def responsive(self, ws_url: str | None = None, timeout: float = 5.0) -> bool:
"""True when the page's renderer still executes JavaScript."""
try:
result = self.call(ws_url or self._page_ws(), "Runtime.evaluate", {"expression": "1+1", "returnByValue": True}, timeout=timeout)
except CDPError:
return False
return result.get("result", {}).get("value") == 2
def screenshot(self, quality: int = 70) -> bytes:
result = self.call(self._page_ws(), "Page.captureScreenshot", {"format": "jpeg", "quality": quality}, timeout=15)
try:
return base64.b64decode(result["data"])
except (KeyError, ValueError) as exc:
raise CDPError("Ongeldige screenshot ontvangen") from exc
def close_browser(self) -> None:
ws_url = self.version().get("webSocketDebuggerUrl")
if not ws_url:
raise CDPError("Geen browser-endpoint gevonden")
self.call(ws_url, "Browser.close")
+249
View File
@@ -0,0 +1,249 @@
"""Kiosk configuration: defaults, validation and atomic persistence.
``load_config`` never raises: a missing, corrupt or partially invalid file
falls back to the ``.bak`` copy and then to defaults per key, so a bad edit
can never keep the kiosk from booting. ``save_config`` validates the whole
result before anything touches the disk.
"""
from __future__ import annotations
import copy
import fcntl
import json
import logging
import os
import tempfile
import threading
from pathlib import Path
from typing import Any, Callable
from . import validation as v
log = logging.getLogger(__name__)
DEFAULTS: dict[str, Any] = {
"device_name": "VoxIP Kiosk",
"url": "https://schermen-ijsselflat.voxip.nl",
"refresh_interval": 0,
"screen_always_on": True,
"screen_off_minutes": 10,
"rotation": 0,
"resolution": "auto",
"zoom": 1.0,
"browser_restart": True,
"browser_profile": "persistent",
"admin_port": 8080,
"admin_https": False,
"admin_allow_public": False,
}
def _bool(value: Any) -> bool:
if isinstance(value, bool):
return value
raise v.ValidationError("Verwacht ja/nee.")
def _int_range(lo: int, hi: int, message: str, allow_zero: bool = False) -> Callable[[Any], int]:
def check(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise v.ValidationError(message)
try:
number = int(value)
except (TypeError, ValueError):
raise v.ValidationError(message) from None
if allow_zero and number == 0:
return 0
if not lo <= number <= hi:
raise v.ValidationError(message)
return number
return check
def _rotation(value: Any) -> int:
try:
number = int(value)
except (TypeError, ValueError):
number = -1
if isinstance(value, bool) or number not in (0, 90, 180, 270):
raise v.ValidationError("Rotatie moet 0, 90, 180 of 270 graden zijn.")
return number
def _zoom(value: Any) -> float:
try:
number = round(float(value), 2)
except (TypeError, ValueError):
number = -1.0
if isinstance(value, bool) or not 0.25 <= number <= 4.0:
raise v.ValidationError("Zoomfactor moet tussen 0,25 en 4 liggen.")
return number
def _choice(*options: str) -> Callable[[Any], str]:
def check(value: Any) -> str:
if value not in options:
raise v.ValidationError("Kies een van: " + ", ".join(options) + ".")
return value
return check
def _str(fn: Callable[[str], Any]) -> Callable[[Any], Any]:
def check(value: Any) -> Any:
if not isinstance(value, str):
raise v.ValidationError("Verwacht tekst.")
return fn(value)
return check
VALIDATORS: dict[str, Callable[[Any], Any]] = {
"device_name": _str(v.device_name),
"url": _str(v.kiosk_url),
"refresh_interval": _int_range(30, 86400, "Verversinterval: 0 (uit) of 30 tot 86400 seconden.", allow_zero=True),
"screen_always_on": _bool,
"screen_off_minutes": _int_range(1, 240, "Scherm uit na: 1 tot 240 minuten."),
"rotation": _rotation,
"resolution": _str(v.resolution),
"zoom": _zoom,
"browser_restart": _bool,
"browser_profile": _choice("persistent", "ephemeral"),
"admin_port": _int_range(1024, 65535, "Poort moet tussen 1024 en 65535 liggen."),
"admin_https": _bool,
"admin_allow_public": _bool,
}
class ConfigError(Exception):
def __init__(self, errors: dict[str, str]):
super().__init__("; ".join(f"{k}: {m}" for k, m in errors.items()))
self.errors = errors
def validate(updates: dict[str, Any]) -> dict[str, Any]:
"""Validate known keys in ``updates``; raise ConfigError listing every problem."""
clean: dict[str, Any] = {}
errors: dict[str, str] = {}
for key, value in updates.items():
check = VALIDATORS.get(key)
if check is None:
errors[key] = "Onbekende instelling."
continue
try:
clean[key] = check(value)
except v.ValidationError as exc:
errors[key] = str(exc)
if errors:
raise ConfigError(errors)
return clean
def _read_json(path: Path) -> dict[str, Any] | None:
try:
with path.open(encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
return None
except (OSError, ValueError) as exc:
log.warning("Kan %s niet lezen: %s", path, exc)
return None
if not isinstance(data, dict):
log.warning("%s bevat geen JSON-object", path)
return None
return data
def _read_raw(path: Path) -> dict[str, Any]:
data = _read_json(path)
if data is None and path.exists():
data = _read_json(path.with_suffix(path.suffix + ".bak"))
if data is not None:
log.warning("Configuratie hersteld uit %s.bak", path.name)
return data or {}
def load_config(path: Path) -> dict[str, Any]:
"""Effective configuration: defaults overlaid with every valid stored value."""
raw = _read_raw(path)
config = copy.deepcopy(DEFAULTS)
for key, value in raw.items():
check = VALIDATORS.get(key)
if check is None:
continue
try:
config[key] = check(value)
except v.ValidationError as exc:
log.warning("Ongeldige waarde voor %s in %s (%s); standaard gebruikt", key, path, exc)
return config
def atomic_write(path: Path, content: bytes, mode: int = 0o640, owner: tuple[int, int] | None = None) -> None:
"""Write ``content`` to ``path`` so readers see either the old or the new file, never a partial one."""
path = Path(path)
if owner is None and path.exists():
st = path.stat()
owner = (st.st_uid, st.st_gid)
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as fh:
fh.write(content)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp, mode)
if owner is not None and os.geteuid() == 0:
os.chown(tmp, *owner)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
dir_fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
def atomic_write_json(path: Path, data: Any, mode: int = 0o640, owner: tuple[int, int] | None = None) -> None:
content = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
atomic_write(path, content, mode=mode, owner=owner)
_thread_lock = threading.Lock()
def save_config(path: Path, updates: dict[str, Any]) -> dict[str, Any]:
"""Validate ``updates``, merge them into the stored file and write it atomically.
Unknown keys already in the file are preserved; the previous version is
kept as ``config.json.bak``. Returns the new effective configuration.
"""
clean = validate(updates)
path = Path(path)
lock_path = path.with_name(".config.lock")
with _thread_lock:
# Read-only is enough for flock and works for every writer (the web
# interface as voxip, the helper as root), whoever created the file.
lock_fd = os.open(str(lock_path), os.O_RDONLY | os.O_CREAT, 0o640)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
return _save_locked(path, clean)
finally:
os.close(lock_fd)
def _save_locked(path: Path, clean: dict[str, Any]) -> dict[str, Any]:
raw = _read_raw(path)
if path.exists():
try:
atomic_write(path.with_suffix(path.suffix + ".bak"), path.read_bytes())
except OSError as exc:
log.warning("Kon geen back-up van de configuratie maken: %s", exc)
raw.update(clean)
atomic_write_json(path, raw)
return load_config(path)
+164
View File
@@ -0,0 +1,164 @@
"""voxip-kiosk-ctl — command-line management over SSH (run with sudo)."""
from __future__ import annotations
import argparse
import grp
import json
import os
import pwd
import subprocess
import sys
from . import services, setupcode
from .agent import read_state
from .cdp import CDP, CDPError
from .config import ConfigError, load_config, save_config
from .healthcheck import HealthCheck
from .helper import Helper, HelperError
from .paths import default_paths, read_version
from .validation import mask_url
def _require_root() -> None:
if os.geteuid() != 0:
sys.exit("Voer dit commando uit met sudo.")
def cmd_status(args) -> int:
paths = default_paths()
config = load_config(paths.config_file)
agent = read_state(paths.agent_state_file)
print(f"VoxIP Kiosk OS {read_version(paths)}")
print(f"Apparaat: {config['device_name']}")
print(f"URL: {config['url'] if args.show_secrets else mask_url(config['url'])}")
print(f"Beheer: {'https' if config['admin_https'] else 'http'}://{os.uname().nodename}.local:{config['admin_port']}")
print(f"Wachtwoord: {'ingesteld' if paths.admin_file.exists() else 'NOG NIET INGESTELD (setupcode: sudo voxip-kiosk-ctl setup-code)'}")
for key, info in services.summary().items():
print(f"{info['unit']:<34} {info['label']} (herstarts: {info['restarts']})")
if agent:
print(f"Pagina: {agent.get('page')} bereikbaar: {agent.get('target_reachable')} {agent.get('reason') or ''}")
return 0
def cmd_setup_code(_args) -> int:
paths = default_paths()
if paths.admin_file.exists():
print("Er is al een beheerderswachtwoord ingesteld. Gebruik 'reset-password' om opnieuw te beginnen.")
return 1
code = setupcode.read(paths.setup_code_file)
if not code:
print("Nog geen setupcode; start de beheerinterface: sudo systemctl restart voxip-kiosk-manager")
return 1
print(code)
return 0
def cmd_reset_password(args) -> int:
_require_root()
paths = default_paths()
if not args.yes:
answer = input("Beheerderswachtwoord verwijderen en een nieuwe setupcode maken? [j/N] ")
if answer.strip().lower() not in ("j", "ja", "y", "yes"):
return 1
paths.admin_file.unlink(missing_ok=True)
code = setupcode.generate()
setupcode.write(paths.setup_code_file, code)
try:
os.chown(paths.setup_code_file, pwd.getpwnam("voxip").pw_uid, grp.getgrnam("kiosk").gr_gid)
except KeyError:
pass
subprocess.run(["systemctl", "restart", "voxip-kiosk-manager.service"], check=False)
print(f"Wachtwoord verwijderd. Nieuwe setupcode: {code}")
print("Open de beheerinterface en stel een nieuw wachtwoord in; de code staat ook op het kioskscherm.")
return 0
def cmd_set_url(args) -> int:
_require_root()
try:
config = save_config(default_paths().config_file, {"url": args.url})
except ConfigError as exc:
print("; ".join(exc.errors.values()), file=sys.stderr)
return 1
print(f"Kiosk-URL ingesteld: {mask_url(config['url'])}")
return 0
def cmd_reload(_args) -> int:
try:
CDP().reload()
except CDPError as exc:
print(exc, file=sys.stderr)
return 1
print("Pagina wordt herladen.")
return 0
def cmd_restart_browser(_args) -> int:
_require_root()
try:
print(Helper().restart_browser()["message"])
except HelperError as exc:
print(exc, file=sys.stderr)
return 1
return 0
def cmd_health(_args) -> int:
report = HealthCheck(default_paths()).run(act=False)
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0
def cmd_maintenance(args) -> int:
_require_root()
flag = default_paths().health_run_dir / "maintenance"
if args.state == "on":
flag.parent.mkdir(parents=True, exist_ok=True)
flag.touch()
print("Onderhoudsmodus aan: de healthcheck herstart niets tot 'maintenance off' of een reboot.")
else:
flag.unlink(missing_ok=True)
print("Onderhoudsmodus uit.")
return 0
def cmd_config(args) -> int:
config = load_config(default_paths().config_file)
if not args.show_secrets:
config["url"] = mask_url(config["url"])
print(json.dumps(config, indent=2, ensure_ascii=False))
return 0
def cmd_version(_args) -> int:
print(read_version(default_paths()))
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="voxip-kiosk-ctl", description="Beheer van VoxIP Kiosk OS vanaf de commandoregel.")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("status", help="toon de status")
p.add_argument("--show-secrets", action="store_true", help="toon de volledige URL inclusief token")
p.set_defaults(func=cmd_status)
sub.add_parser("setup-code", help="toon de setupcode voor de eerste configuratie").set_defaults(func=cmd_setup_code)
p = sub.add_parser("reset-password", help="verwijder het beheerderswachtwoord (nieuwe setupcode)")
p.add_argument("-y", "--yes", action="store_true")
p.set_defaults(func=cmd_reset_password)
p = sub.add_parser("set-url", help="stel de kiosk-URL in")
p.add_argument("url")
p.set_defaults(func=cmd_set_url)
sub.add_parser("reload", help="herlaad de pagina").set_defaults(func=cmd_reload)
sub.add_parser("restart-browser", help="herstart Chromium").set_defaults(func=cmd_restart_browser)
sub.add_parser("health", help="healthcheck-rapport (zonder herstelacties)").set_defaults(func=cmd_health)
p = sub.add_parser("maintenance", help="onderhoudsmodus aan/uit")
p.add_argument("state", choices=("on", "off"))
p.set_defaults(func=cmd_maintenance)
p = sub.add_parser("config", help="toon de configuratie")
p.add_argument("--show-secrets", action="store_true")
p.set_defaults(func=cmd_config)
sub.add_parser("version", help="toon de versie").set_defaults(func=cmd_version)
args = parser.parse_args(argv)
return args.func(args)
+161
View File
@@ -0,0 +1,161 @@
"""voxip-kiosk-healthcheck — runs every minute (timer) as root.
Keeps every kiosk service alive without hiding problems: each recovery is
logged, and a crash loop that hit systemd's start limit is retried only
after a cool-down, so the journal shows the loop instead of an endless
stream of restarts.
"""
from __future__ import annotations
import argparse
import json
import logging
import subprocess
import time
from pathlib import Path
from typing import Any
from . import logutil, services, sysinfo
from .cdp import CDP, CDPError
from .config import load_config
from .paths import Paths, load_paths
log = logging.getLogger("voxip-kiosk-healthcheck")
ALWAYS_ON = (services.AGENT_UNIT, services.MANAGER_UNIT)
DISPLAY_UNITS = (services.X_UNIT, services.BROWSER_UNIT)
DISPLAY_RETRY_AFTER = 300
CDP_GRACE = 90
CDP_FAILURES_BEFORE_RESTART = 3
WARN_EVERY = 3600
def _systemctl(*args: str) -> None:
try:
subprocess.run(["systemctl", *args], capture_output=True, timeout=30)
except (OSError, subprocess.SubprocessError) as exc:
log.error("systemctl %s mislukt: %s", " ".join(args), exc)
class HealthCheck:
def __init__(self, paths: Paths, unit_status=services.unit_status, systemctl=_systemctl, cdp: Any = None,
throttled=sysinfo.throttled, disk=sysinfo.disk, now=time.time):
self.paths = paths
self.unit_status = unit_status
self.systemctl = systemctl
self.cdp = cdp or CDP(timeout=4)
self.throttled = throttled
self.disk = disk
self.now = now
def load_state(self) -> dict[str, Any]:
try:
data = json.loads(self.paths.health_state_file.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def save_state(self, state: dict[str, Any]) -> None:
try:
self.paths.health_state_file.parent.mkdir(parents=True, exist_ok=True)
self.paths.health_state_file.write_text(json.dumps(state), encoding="utf-8")
except OSError as exc:
log.warning("Kon healthcheck-status niet opslaan: %s", exc)
def maintenance(self) -> bool:
return (self.paths.health_run_dir / "maintenance").exists()
def run(self, act: bool = True) -> dict[str, Any]:
now = self.now()
state = self.load_state()
failed_since: dict[str, float] = state.setdefault("failed_since", {})
warned: dict[str, float] = state.setdefault("warned", {})
config = load_config(self.paths.config_file)
report: dict[str, Any] = {"units": {}, "actions": [], "maintenance": self.maintenance()}
act = act and not report["maintenance"]
for unit in (*DISPLAY_UNITS, *ALWAYS_ON):
props = self.unit_status(unit)
active = props.get("ActiveState", "unknown")
report["units"][unit] = active
is_display = unit in DISPLAY_UNITS
if active == "failed":
if unit not in failed_since:
failed_since[unit] = now
log.error("%s is in fouttoestand (resultaat: %s, herstarts: %s)", unit, props.get("Result"), props.get("NRestarts"))
if is_display and not config["browser_restart"]:
continue
wait = DISPLAY_RETRY_AFTER if is_display else 0
if act and now - failed_since[unit] >= wait:
log.warning("%s opnieuw starten na fouttoestand", unit)
self.systemctl("reset-failed", unit)
self.systemctl("start", "--no-block", unit)
report["actions"].append(f"start {unit}")
del failed_since[unit]
elif active == "inactive":
failed_since.pop(unit, None)
should_run = not is_display or config["browser_restart"]
if act and should_run:
log.warning("%s draaide niet; wordt gestart", unit)
self.systemctl("start", "--no-block", unit)
report["actions"].append(f"start {unit}")
else:
failed_since.pop(unit, None)
self._check_devtools(state, report, now, act)
self._check_hardware(warned, report, now)
self.save_state(state)
return report
def _check_devtools(self, state: dict[str, Any], report: dict[str, Any], now: float, act: bool) -> None:
if report["units"].get(services.BROWSER_UNIT) != "active":
state.pop("browser_active_since", None)
state["cdp_failures"] = 0
return
since = state.setdefault("browser_active_since", now)
if now - since < CDP_GRACE:
return
try:
self.cdp.version()
state["cdp_failures"] = 0
report["devtools"] = "ok"
except CDPError:
state["cdp_failures"] = state.get("cdp_failures", 0) + 1
report["devtools"] = "geen antwoord"
log.warning("Chromium reageert niet op DevTools (%s keer achter elkaar)", state["cdp_failures"])
if act and state["cdp_failures"] >= CDP_FAILURES_BEFORE_RESTART:
log.error("Chromium wordt herstart: DevTools reageert niet")
self.systemctl("restart", "--no-block", services.BROWSER_UNIT)
report["actions"].append(f"restart {services.BROWSER_UNIT}")
state["cdp_failures"] = 0
state.pop("browser_active_since", None)
def _check_hardware(self, warned: dict[str, float], report: dict[str, Any], now: float) -> None:
throttle = self.throttled()
if throttle and throttle["now"]:
report["throttled"] = throttle["flags"]
if now - warned.get("throttled", 0) >= WARN_EVERY:
log.warning("Voeding/temperatuur: %s — controleer de voedingsadapter", ", ".join(throttle["flags"]))
warned["throttled"] = now
usage = self.disk("/")
if usage and usage["percent"] >= 90:
report["disk_percent"] = usage["percent"]
if now - warned.get("disk", 0) >= WARN_EVERY:
log.warning("SD-kaart is voor %s%% vol", usage["percent"])
warned["disk"] = now
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="voxip-kiosk-healthcheck")
parser.add_argument("--check-only", action="store_true", help="alleen rapporteren, niets herstellen")
parser.add_argument("--json", action="store_true", help="rapport als JSON")
args = parser.parse_args(argv)
logutil.setup()
report = HealthCheck(load_paths()).run(act=not args.check_only)
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
else:
units = ", ".join(f"{Path(u).stem}={s}" for u, s in report["units"].items())
log.info("Healthcheck: %s%s", units, f"; acties: {', '.join(report['actions'])}" if report["actions"] else "")
return 0
+446
View File
@@ -0,0 +1,446 @@
"""voxip-kiosk-helper — the only privileged entry point of VoxIP Kiosk OS.
Runs as root, either through ``sudo -n`` from the web interface (user
``voxip``) or from systemd units. It accepts a fixed set of verbs, validates
every argument itself, never invokes a shell and prints a single JSON
object on stdout.
Usage: voxip-kiosk-helper <verb> [args] (network-apply reads JSON on stdin)
"""
from __future__ import annotations
import grp
import json
import os
import pwd
import shutil
import subprocess
import sys
import syslog
import time
from pathlib import Path
from typing import Any, Callable
from . import config as cfg
from . import netconfig
from . import validation as v
from .paths import Paths, default_paths
HELPER_PATH = "/usr/lib/voxip-kiosk/voxip-kiosk-helper"
ROLLBACK_UNIT = "voxip-network-rollback"
ACTIVATE_UNIT = "voxip-network-activate"
BOOT_DIRS = (Path("/boot/firmware"), Path("/boot"))
RESET_TRIGGER = "voxip-network-reset"
IMPORT_CONFIG = "voxip-config.json"
IMPORT_KEYS = "voxip-authorized_keys"
SSH_KEY_TYPES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-")
# Verbs the web interface may call through sudo. Everything else is only
# reachable from systemd units or a root shell.
PUBLIC_VERBS = {
"reboot", "poweroff", "restart-browser", "restart-manager", "set-hostname", "set-wifi-country",
"wifi-scan", "network-apply", "network-confirm", "network-rollback", "network-pending",
}
class HelperError(Exception):
pass
def log(priority: int, message: str) -> None:
syslog.syslog(priority, message)
print(message, file=sys.stderr)
class Runner:
"""subprocess wrapper; tests replace it with a recorder."""
def __call__(self, args: list[str], timeout: float = 30, check: bool = True, input: str | None = None) -> subprocess.CompletedProcess:
try:
proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=input)
except (OSError, subprocess.SubprocessError) as exc:
raise HelperError(f"{args[0]}: {exc}") from exc
if check and proc.returncode != 0:
raise HelperError(f"{' '.join(args[:3])} mislukt: {(proc.stderr or proc.stdout).strip()[:300]}")
return proc
class Helper:
def __init__(self, paths: Paths | None = None, run: Callable[..., Any] | None = None,
etc: Path = Path("/etc"), boot_dirs: tuple[Path, ...] = BOOT_DIRS, helper_path: str = HELPER_PATH):
self.paths = paths or default_paths()
self.run = run or Runner()
self.etc = etc
self.boot_dirs = boot_dirs
self.helper_path = helper_path
# -- power & browser -------------------------------------------------
def reboot(self) -> dict[str, Any]:
self.run(["systemd-run", "--on-active=3", "--unit=voxip-power-reboot", "/bin/systemctl", "reboot"])
log(syslog.LOG_NOTICE, "Herstart aangevraagd via beheerinterface")
return {"message": "De Raspberry Pi wordt over enkele seconden herstart."}
def poweroff(self) -> dict[str, Any]:
self.run(["systemd-run", "--on-active=3", "--unit=voxip-power-off", "/bin/systemctl", "poweroff"])
log(syslog.LOG_NOTICE, "Uitschakelen aangevraagd via beheerinterface")
return {"message": "De Raspberry Pi wordt over enkele seconden uitgeschakeld."}
def restart_browser(self) -> dict[str, Any]:
self.run(["systemctl", "reset-failed", "voxip-kiosk-browser.service", "voxip-kiosk-x.service"], check=False)
self.run(["systemctl", "restart", "--no-block", "voxip-kiosk-browser.service"])
log(syslog.LOG_NOTICE, "Chromium wordt herstart")
return {"message": "Chromium wordt herstart."}
def restart_manager(self) -> dict[str, Any]:
# Delayed so the web interface can still answer the request that asked for it.
self.run(["systemd-run", "--on-active=2", "--unit=voxip-manager-restart", "/bin/systemctl", "restart", "voxip-kiosk-manager.service"])
log(syslog.LOG_NOTICE, "Beheerinterface wordt herstart")
return {"message": "De beheerinterface wordt herstart."}
# -- hostname -------------------------------------------------------
def set_hostname(self, name: str) -> dict[str, Any]:
name = v.hostname(name)
cfg.atomic_write(self.etc / "hostname", (name + "\n").encode(), mode=0o644, owner=(0, 0))
hosts_file = self.etc / "hosts"
try:
lines = hosts_file.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
lines = ["127.0.0.1\tlocalhost"]
lines = [line for line in lines if not line.startswith("127.0.1.1")]
lines.append(f"127.0.1.1\t{name}")
cfg.atomic_write(hosts_file, ("\n".join(lines) + "\n").encode(), mode=0o644, owner=(0, 0))
self.run(["hostname", name], check=False)
self.run(["systemctl", "try-restart", "--no-block", "avahi-daemon.service"], check=False)
log(syslog.LOG_NOTICE, f"Hostname gewijzigd naar {name}")
return {"message": f"Hostname is nu {name}. Beheer bereikbaar via http://{name}.local.", "hostname": name}
# -- wifi -----------------------------------------------------------
def set_wifi_country(self, code: str) -> dict[str, Any]:
code = v.country_code(code)
if shutil.which("raspi-config"):
self.run(["raspi-config", "nonint", "do_wifi_country", code])
else:
self.run(["iw", "reg", "set", code])
self.run(["rfkill", "unblock", "wifi"], check=False)
log(syslog.LOG_NOTICE, f"Wifi-land ingesteld op {code}")
return {"message": f"Wifi-land ingesteld op {code}.", "country": code}
def wifi_scan(self) -> dict[str, Any]:
self.run(["rfkill", "unblock", "wifi"], check=False)
proc = self.run(["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "ifname", "wlan0", "--rescan", "yes"], timeout=40)
return {"networks": netconfig.parse_wifi_scan(proc.stdout)}
# -- network change with rollback -------------------------------------
def _pending(self) -> dict[str, Any] | None:
try:
data = json.loads(self.paths.network_pending_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _keyfile_path(self, interface: str) -> Path:
return self.paths.nm_connections_dir / f"{netconfig.profile_name(interface)}.nmconnection"
def _existing_profile(self, interface: str):
try:
return netconfig.read_keyfile(self._keyfile_path(interface).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _active_uuids(self) -> list[str]:
proc = self.run(["nmcli", "-t", "-f", "UUID", "connection", "show", "--active"], check=False)
return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]
def _snapshot(self) -> None:
backup = self.paths.network_backup_dir
if backup.exists():
shutil.rmtree(backup)
backup.mkdir(parents=True, mode=0o700)
for item in self.paths.nm_connections_dir.iterdir():
if item.is_file():
shutil.copy2(item, backup / item.name)
def _schedule(self, unit: str, delay: int, *args: str) -> None:
self.run(["systemctl", "stop", f"{unit}.timer", f"{unit}.service"], check=False)
self.run(["systemctl", "reset-failed", f"{unit}.timer", f"{unit}.service"], check=False)
self.run(["systemd-run", f"--unit={unit}", f"--on-active={delay}", "--timer-property=AccuracySec=1s", self.helper_path, *args])
def network_apply(self, payload: Any) -> dict[str, Any]:
req = netconfig.validate_request(payload)
if self._pending():
raise HelperError("Er staat al een netwerkwijziging open. Bevestig of draai die eerst terug.")
existing = self._existing_profile(req["interface"])
uuid = existing.get("connection", "uuid", fallback=None) if existing else None
psk = ""
if req["interface"] == "wlan0" and not req["wifi"]["open"]:
psk = req["wifi"]["psk"]
if not psk and existing is not None:
old_ssid = netconfig.unescape(existing.get("wifi", "ssid", fallback=""))
if old_ssid == req["wifi"]["ssid"]:
psk = netconfig.unescape(existing.get("wifi-security", "psk", fallback=""))
if not psk:
raise HelperError("Vul het wifi-wachtwoord in.")
keyfile = netconfig.build_keyfile(req, uuid=uuid, psk=psk)
active_before = self._active_uuids()
self.paths.network_state_dir.mkdir(parents=True, exist_ok=True, mode=0o755)
self._snapshot()
now = int(time.time())
pending = {
"interface": req["interface"],
"method": req["method"],
"address": req.get("address", ""),
"created": now,
"deadline": now + netconfig.ROLLBACK_SECONDS,
"active_before": active_before,
}
try:
cfg.atomic_write_json(self.paths.network_pending_file, pending, mode=0o644, owner=(0, 0))
cfg.atomic_write(self._keyfile_path(req["interface"]), keyfile.encode("utf-8"), mode=0o600, owner=(0, 0))
self.run(["nmcli", "connection", "reload"])
self._schedule(ROLLBACK_UNIT, netconfig.ROLLBACK_SECONDS, "network-restore")
self._schedule(ACTIVATE_UNIT, 2, "network-activate", req["interface"])
except Exception:
self.network_restore()
raise
log(syslog.LOG_NOTICE, f"Netwerkwijziging voor {req['interface']} toegepast; rollback over {netconfig.ROLLBACK_SECONDS}s tenzij bevestigd")
return {"message": "Netwerkwijziging wordt toegepast.", "pending": pending}
def network_activate(self, interface: str) -> dict[str, Any]:
if interface not in netconfig.INTERFACES:
raise HelperError("Onbekende interface")
proc = self.run(["nmcli", "--wait", "45", "connection", "up", "id", netconfig.profile_name(interface)], timeout=60, check=False)
if proc.returncode != 0:
log(syslog.LOG_WARNING, f"Activeren van {netconfig.profile_name(interface)} mislukt: {(proc.stderr or '').strip()}")
return {"activated": False}
log(syslog.LOG_INFO, f"{netconfig.profile_name(interface)} geactiveerd")
return {"activated": True}
def network_confirm(self) -> dict[str, Any]:
if not self._pending():
return {"message": "Er stond geen wijziging open."}
self.run(["systemctl", "stop", f"{ROLLBACK_UNIT}.timer"], check=False)
self.paths.network_pending_file.unlink(missing_ok=True)
shutil.rmtree(self.paths.network_backup_dir, ignore_errors=True)
log(syslog.LOG_NOTICE, "Netwerkwijziging bevestigd")
return {"message": "Netwerkwijziging bevestigd."}
def network_rollback(self) -> dict[str, Any]:
"""Public rollback: runs asynchronously so the HTTP response still arrives."""
if not self._pending():
return {"message": "Er stond geen wijziging open."}
self._schedule(ROLLBACK_UNIT, 1, "network-restore")
return {"message": "De vorige netwerkinstellingen worden teruggezet."}
def _restore_files(self) -> bool:
backup = self.paths.network_backup_dir
if not backup.is_dir():
log(syslog.LOG_ERR, "Geen netwerk-back-up gevonden; niets teruggezet")
return False
target = self.paths.nm_connections_dir
for item in target.iterdir():
if item.is_file():
item.unlink()
for item in backup.iterdir():
shutil.copy2(item, target / item.name)
os.chmod(target / item.name, 0o600)
return True
def network_restore(self, boot: bool = False) -> dict[str, Any]:
pending = self._pending()
if pending is None:
return {"message": "Er stond geen wijziging open."}
if not boot:
for iface in netconfig.INTERFACES:
self.run(["nmcli", "connection", "down", "id", netconfig.profile_name(iface)], check=False)
restored = self._restore_files()
if not boot and restored:
self.run(["nmcli", "connection", "reload"], check=False)
for uuid in pending.get("active_before", []):
if isinstance(uuid, str) and len(uuid) <= 64:
self.run(["nmcli", "--wait", "30", "connection", "up", "uuid", uuid], timeout=45, check=False)
self.paths.network_pending_file.unlink(missing_ok=True)
shutil.rmtree(self.paths.network_backup_dir, ignore_errors=True)
if not boot:
self.run(["systemctl", "stop", f"{ROLLBACK_UNIT}.timer"], check=False)
log(syslog.LOG_WARNING, "Netwerkwijziging teruggedraaid" + (" bij opstarten" if boot else ""))
return {"message": "Vorige netwerkinstellingen teruggezet."}
def network_pending(self) -> dict[str, Any]:
return {"pending": self._pending()}
def _boot_dir(self) -> Path | None:
for d in self.boot_dirs:
if (d / "config.txt").exists() or (d / "cmdline.txt").exists():
return d
return None
def network_boot_guard(self) -> dict[str, Any]:
"""Before NetworkManager starts: undo an unconfirmed change and honour the reset trigger."""
result: dict[str, Any] = {}
if self._pending():
self.network_restore(boot=True)
result["rolled_back"] = True
boot = self._boot_dir()
trigger = boot / RESET_TRIGGER if boot else None
if trigger and trigger.exists():
stash = self.paths.network_state_dir / f"disabled-{int(time.time())}"
stash.mkdir(parents=True, mode=0o700)
for iface in netconfig.INTERFACES:
src = self._keyfile_path(iface)
if src.exists():
shutil.move(str(src), str(stash / src.name))
trigger.unlink()
log(syslog.LOG_WARNING, f"Netwerk-reset uitgevoerd; VoxIP-profielen verplaatst naar {stash}")
result["reset"] = True
return result
# -- provisioning from the boot partition ----------------------------
def _chown_config(self, path: Path) -> None:
try:
uid = pwd.getpwnam("voxip").pw_uid
gid = grp.getgrnam("kiosk").gr_gid
except KeyError:
return
os.chown(path, uid, gid)
os.chmod(path, 0o640)
def boot_import(self) -> dict[str, Any]:
boot = self._boot_dir()
if boot is None:
return {"imported": []}
imported = []
conf = boot / IMPORT_CONFIG
if conf.exists():
try:
data = json.loads(conf.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise HelperError("voxip-config.json moet een JSON-object zijn")
data = dict(data)
host = data.pop("hostname", None)
country = data.pop("wifi_country", None)
if data:
cfg.save_config(self.paths.config_file, data)
self._chown_config(self.paths.config_file)
if host:
self.set_hostname(str(host))
if country:
self.set_wifi_country(str(country))
conf.rename(conf.with_name(IMPORT_CONFIG + ".applied"))
imported.append(IMPORT_CONFIG)
log(syslog.LOG_NOTICE, "Configuratie geïmporteerd vanaf de bootpartitie")
except (HelperError, cfg.ConfigError, v.ValidationError, ValueError, OSError) as exc:
(boot / "voxip-config.error.txt").write_text(f"{exc}\n", encoding="utf-8")
conf.rename(conf.with_name(IMPORT_CONFIG + ".rejected"))
log(syslog.LOG_ERR, f"voxip-config.json geweigerd: {exc}")
keys = boot / IMPORT_KEYS
if keys.exists():
try:
self._import_ssh_keys(keys.read_text(encoding="utf-8-sig"))
keys.rename(keys.with_name(IMPORT_KEYS + ".applied"))
imported.append(IMPORT_KEYS)
except (HelperError, OSError) as exc:
keys.rename(keys.with_name(IMPORT_KEYS + ".rejected"))
log(syslog.LOG_ERR, f"SSH-sleutels geweigerd: {exc}")
return {"imported": imported}
def _import_ssh_keys(self, text: str) -> None:
keys = [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")]
if not keys or any(not k.startswith(SSH_KEY_TYPES) or len(k) > 8192 for k in keys):
raise HelperError("Bestand bevat geen geldige openbare SSH-sleutels")
user = next((u for u in pwd.getpwall() if u.pw_uid == 1000), None)
if user is None:
raise HelperError("Geen beheerdersaccount (uid 1000) gevonden")
ssh_dir = Path(user.pw_dir) / ".ssh"
ssh_dir.mkdir(mode=0o700, exist_ok=True)
auth = ssh_dir / "authorized_keys"
existing = auth.read_text(encoding="utf-8").splitlines() if auth.exists() else []
merged = existing + [k for k in keys if k not in existing]
cfg.atomic_write(auth, ("\n".join(merged) + "\n").encode(), mode=0o600, owner=(user.pw_uid, user.pw_gid))
os.chown(ssh_dir, user.pw_uid, user.pw_gid)
sshd_dir = self.etc / "ssh" / "sshd_config.d"
sshd_dir.mkdir(parents=True, exist_ok=True)
(sshd_dir / "voxip-kiosk.conf").write_text(
"# Beheerd door VoxIP Kiosk OS: alleen inloggen met SSH-sleutels.\n"
"PasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\n",
encoding="utf-8",
)
self.run(["systemctl", "enable", "ssh.service"], check=False)
self.run(["systemctl", "start", "--no-block", "ssh.service"], check=False)
log(syslog.LOG_NOTICE, f"{len(keys)} SSH-sleutel(s) geïnstalleerd voor {user.pw_name}; SSH ingeschakeld")
# -- dispatch ---------------------------------------------------------
def dispatch(self, verb: str, args: list[str], stdin: str = "") -> dict[str, Any]:
def one_arg() -> str:
if len(args) != 1:
raise HelperError(f"{verb} verwacht precies één argument")
return args[0]
def no_args() -> None:
if args:
raise HelperError(f"{verb} verwacht geen argumenten")
table: dict[str, Callable[[], dict[str, Any]]] = {
"reboot": lambda: (no_args(), self.reboot())[1],
"poweroff": lambda: (no_args(), self.poweroff())[1],
"restart-browser": lambda: (no_args(), self.restart_browser())[1],
"restart-manager": lambda: (no_args(), self.restart_manager())[1],
"set-hostname": lambda: self.set_hostname(one_arg()),
"set-wifi-country": lambda: self.set_wifi_country(one_arg()),
"wifi-scan": lambda: (no_args(), self.wifi_scan())[1],
"network-apply": lambda: (no_args(), self.network_apply(_parse_json(stdin)))[1],
"network-activate": lambda: self.network_activate(one_arg()),
"network-confirm": lambda: (no_args(), self.network_confirm())[1],
"network-rollback": lambda: (no_args(), self.network_rollback())[1],
"network-restore": lambda: (no_args(), self.network_restore())[1],
"network-pending": lambda: (no_args(), self.network_pending())[1],
"network-boot-guard": lambda: (no_args(), self.network_boot_guard())[1],
"boot-import": lambda: (no_args(), self.boot_import())[1],
}
if verb not in table:
raise HelperError(f"Onbekende opdracht: {verb}")
return table[verb]()
def _parse_json(text: str) -> Any:
if len(text) > 16384:
raise HelperError("Invoer te groot")
try:
return json.loads(text)
except ValueError as exc:
raise HelperError("Ongeldige JSON-invoer") from exc
def main(argv: list[str] | None = None, helper: Helper | None = None, environ: dict[str, str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
environ = os.environ if environ is None else environ
syslog.openlog("voxip-kiosk-helper", 0, syslog.LOG_DAEMON)
if not argv:
print(json.dumps({"ok": False, "error": "Gebruik: voxip-kiosk-helper <opdracht> [argumenten]"}))
return 2
verb, args = argv[0], argv[1:]
caller = environ.get("SUDO_USER")
if caller and verb not in PUBLIC_VERBS:
log(syslog.LOG_WARNING, f"Geweigerd: {caller} probeerde interne opdracht {verb}")
print(json.dumps({"ok": False, "error": "Opdracht niet toegestaan"}))
return 1
if helper is None and os.geteuid() != 0:
print(json.dumps({"ok": False, "error": "voxip-kiosk-helper moet als root draaien"}))
return 1
helper = helper or Helper()
stdin = sys.stdin.read(16385) if verb == "network-apply" else ""
try:
result = helper.dispatch(verb, args, stdin)
except (HelperError, v.ValidationError, cfg.ConfigError) as exc:
log(syslog.LOG_WARNING, f"{verb} mislukt: {exc}")
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
return 0
+37
View File
@@ -0,0 +1,37 @@
"""Calls the root helper through ``sudo -n`` with a fixed argument list."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any
class HelperError(Exception):
pass
class HelperClient:
def __init__(self, helper_path: Path):
self.helper_path = str(helper_path)
def call(self, verb: str, *args: str, payload: Any = None, timeout: float = 60) -> dict[str, Any]:
cmd = ["sudo", "-n", self.helper_path, verb, *args]
try:
proc = subprocess.run(
cmd, input=json.dumps(payload) if payload is not None else "",
capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
raise HelperError("De opdracht duurde te lang.") from None
except OSError as exc:
raise HelperError(f"Systeemhelper niet beschikbaar: {exc}") from None
try:
result = json.loads(proc.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
detail = (proc.stderr or "").strip().splitlines()
raise HelperError(detail[-1] if detail else "Onverwacht antwoord van de systeemhelper.") from None
if not result.get("ok"):
raise HelperError(result.get("error") or "Opdracht mislukt.")
return result
+23
View File
@@ -0,0 +1,23 @@
"""Logging to stdout with sd-daemon priority prefixes, so journald records
warnings and errors at the right level (``journalctl -p warning`` works)."""
from __future__ import annotations
import logging
import sys
_PRIORITY = {logging.DEBUG: 7, logging.INFO: 6, logging.WARNING: 4, logging.ERROR: 3, logging.CRITICAL: 2}
class JournalFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
prio = _PRIORITY.get(record.levelno, 6)
return "\n".join(f"<{prio}>{line}" for line in super().format(record).splitlines() or [""])
def setup(level: int = logging.INFO) -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JournalFormatter("%(message)s"))
root = logging.getLogger()
root.handlers[:] = [handler]
root.setLevel(level)
+69
View File
@@ -0,0 +1,69 @@
"""voxip-kiosk-manager — starts the web interface under gunicorn.
Reads port and HTTPS settings from the kiosk configuration, creates a
self-signed certificate when HTTPS is enabled and none exists yet, then
replaces itself with gunicorn (one worker so the login throttle is shared).
"""
from __future__ import annotations
import logging
import os
import shutil
import socket
import subprocess
import sys
from pathlib import Path
from . import logutil
from .config import load_config
from .paths import Paths, load_paths
log = logging.getLogger("voxip-kiosk-manager")
PYTHON_LIB = "/usr/lib/voxip-kiosk/python"
def ensure_certificate(paths: Paths) -> tuple[Path, Path]:
cert, key = paths.tls_dir / "cert.pem", paths.tls_dir / "key.pem"
if cert.exists() and key.exists():
return cert, key
paths.tls_dir.mkdir(mode=0o700, exist_ok=True)
host = socket.gethostname()
log.info("Zelfondertekend certificaat aanmaken voor %s.local", host)
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650",
"-subj", f"/CN={host}.local/O=VoxIP Kiosk OS",
"-addext", f"subjectAltName=DNS:{host}.local,DNS:{host},IP:127.0.0.1",
"-keyout", str(key), "-out", str(cert),
], check=True, capture_output=True, timeout=120)
os.chmod(key, 0o600)
return cert, key
def gunicorn_argv(paths: Paths, config: dict) -> list[str]:
binary = shutil.which("gunicorn") or shutil.which("gunicorn3")
argv = [binary] if binary else [sys.executable, "-m", "gunicorn"]
bind = f"[::]:{config['admin_port']}" if Path("/proc/net/if_inet6").exists() else f"0.0.0.0:{config['admin_port']}"
argv += [
"--workers", "1", "--threads", "4", "--worker-class", "gthread",
"--bind", bind,
"--timeout", "90", "--graceful-timeout", "10", "--keep-alive", "5",
"--worker-tmp-dir", "/dev/shm",
"--error-logfile", "-", "--log-level", "warning",
"--pythonpath", PYTHON_LIB,
]
if config["admin_https"]:
cert, key = ensure_certificate(paths)
argv += ["--certfile", str(cert), "--keyfile", str(key)]
argv.append("voxip_kiosk.web:create_app()")
return argv
def main() -> int:
logutil.setup()
paths = load_paths()
config = load_config(paths.config_file)
argv = gunicorn_argv(paths, config)
log.info("VoxIP Kiosk Manager start op poort %s (%s)", config["admin_port"], "HTTPS" if config["admin_https"] else "HTTP")
os.execv(argv[0], argv)
return 1 # not reached
+181
View File
@@ -0,0 +1,181 @@
"""NetworkManager configuration model used by the root helper.
The helper writes keyfiles itself instead of passing settings to ``nmcli``
on the command line: secrets never show up in the process list, and a
rollback is a plain file restore.
"""
from __future__ import annotations
import configparser
import io
import uuid as uuidlib
from typing import Any
from . import validation as v
INTERFACES = {"eth0": "ethernet", "wlan0": "wifi"}
PROFILE_PREFIX = "voxip-"
ROLLBACK_SECONDS = 180
def profile_name(interface: str) -> str:
return PROFILE_PREFIX + interface
def validate_request(payload: Any) -> dict[str, Any]:
"""Validate a network change request coming from the web interface."""
if not isinstance(payload, dict):
raise v.ValidationError("Ongeldig verzoek.")
interface = payload.get("interface")
if interface not in INTERFACES:
raise v.ValidationError("Onbekende netwerkinterface.")
method = payload.get("method")
if method not in ("dhcp", "static"):
raise v.ValidationError("Kies DHCP of een statisch adres.")
clean: dict[str, Any] = {"interface": interface, "method": method}
if method == "static":
clean["address"] = v.ipv4_interface(str(payload.get("address", "")))
gateway = str(payload.get("gateway", "")).strip()
if gateway:
import ipaddress
gw = v.ipv4_address(gateway)
if ipaddress.IPv4Address(gw) not in ipaddress.IPv4Interface(clean["address"]).network:
raise v.ValidationError("De gateway ligt niet in hetzelfde subnet als het IP-adres.")
clean["gateway"] = gw
else:
clean["gateway"] = ""
dns = payload.get("dns", [])
if not isinstance(dns, (list, str)):
raise v.ValidationError("Ongeldige DNS-servers.")
clean["dns"] = v.dns_servers(dns)
if method == "static" and not clean["dns"]:
raise v.ValidationError("Vul bij een statisch adres minimaal één DNS-server in.")
if interface == "wlan0":
wifi = payload.get("wifi")
if not isinstance(wifi, dict):
raise v.ValidationError("Wifi-gegevens ontbreken.")
psk = wifi.get("psk") or ""
clean["wifi"] = {
"ssid": v.ssid(str(wifi.get("ssid", ""))),
"psk": v.wifi_psk(psk) if psk else "",
"hidden": bool(wifi.get("hidden", False)),
"open": bool(wifi.get("open", False)),
}
return clean
def _escape(value: str) -> str:
"""Escape a value for a GLib key file (the format NetworkManager uses)."""
out = value.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r")
if out.startswith(" "):
out = "\\s" + out[1:]
return out
def build_keyfile(req: dict[str, Any], uuid: str | None = None, psk: str = "") -> str:
"""Render a NetworkManager keyfile for a validated request."""
interface = req["interface"]
name = profile_name(interface)
lines = [
"[connection]",
f"id={name}",
f"uuid={uuid or uuidlib.uuid4()}",
f"type={INTERFACES[interface]}",
f"interface-name={interface}",
"autoconnect=true",
"autoconnect-priority=100",
"",
]
if interface == "wlan0":
wifi = req["wifi"]
lines += ["[wifi]", "mode=infrastructure", f"ssid={_escape(wifi['ssid'])}", f"hidden={'true' if wifi['hidden'] else 'false'}", ""]
if not wifi["open"]:
if not psk:
raise v.ValidationError("Vul het wifi-wachtwoord in.")
lines += ["[wifi-security]", "key-mgmt=wpa-psk", f"psk={_escape(psk)}", ""]
else:
lines += ["[ethernet]", ""]
lines.append("[ipv4]")
if req["method"] == "static":
lines.append("method=manual")
address = req["address"]
lines.append(f"address1={address},{req['gateway']}" if req.get("gateway") else f"address1={address}")
else:
lines.append("method=auto")
if req["dns"]:
ipv4_dns = [d for d in req["dns"] if ":" not in d]
if ipv4_dns:
lines.append("dns=" + ";".join(ipv4_dns) + ";")
lines.append("ignore-auto-dns=true")
lines += ["", "[ipv6]", "addr-gen-mode=default", "method=auto"]
ipv6_dns = [d for d in req["dns"] if ":" in d]
if ipv6_dns:
lines.append("dns=" + ";".join(ipv6_dns) + ";")
lines += ["", "[proxy]", ""]
return "\n".join(lines)
def read_keyfile(text: str) -> configparser.ConfigParser:
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = str # keep key case
parser.read_file(io.StringIO(text))
return parser
def unescape(value: str) -> str:
out, i = [], 0
mapping = {"\\": "\\", "n": "\n", "t": "\t", "r": "\r", "s": " "}
while i < len(value):
ch = value[i]
if ch == "\\" and i + 1 < len(value) and value[i + 1] in mapping:
out.append(mapping[value[i + 1]])
i += 2
else:
out.append(ch)
i += 1
return "".join(out)
def split_terse(line: str) -> list[str]:
"""Split one line of ``nmcli -t`` output, honouring ``\\:`` and ``\\\\`` escapes."""
fields, current, i = [], [], 0
while i < len(line):
ch = line[i]
if ch == "\\" and i + 1 < len(line):
current.append(line[i + 1])
i += 2
continue
if ch == ":":
fields.append("".join(current))
current = []
else:
current.append(ch)
i += 1
fields.append("".join(current))
return fields
def parse_wifi_scan(output: str) -> list[dict[str, Any]]:
"""Parse ``nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list``."""
networks: dict[str, dict[str, Any]] = {}
for line in output.splitlines():
fields = split_terse(line)
if len(fields) < 4:
continue
in_use, ssid, signal, security = fields[:4]
if not ssid:
continue
try:
strength = int(signal)
except ValueError:
strength = 0
entry = {"ssid": ssid, "signal": strength, "security": security.strip() or "open", "in_use": in_use.strip() == "*"}
current = networks.get(ssid)
if current is None or strength > current["signal"]:
entry["in_use"] = entry["in_use"] or bool(current and current["in_use"])
networks[ssid] = entry
elif entry["in_use"]:
current["in_use"] = True
return sorted(networks.values(), key=lambda n: (-n["in_use"], -n["signal"], n["ssid"].lower()))
+127
View File
@@ -0,0 +1,127 @@
"""Network status for the web interface (read-only, no privileges needed)."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
from .netconfig import INTERFACES, profile_name, split_terse
STATE_LABELS = {
"connected": "Verbonden",
"connecting": "Bezig met verbinden",
"disconnected": "Niet verbonden",
"unavailable": "Niet beschikbaar",
"unmanaged": "Niet beheerd",
}
def _nmcli(*args: str) -> str | None:
if not shutil.which("nmcli"):
return None
try:
proc = subprocess.run(["nmcli", *args], capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None
return proc.stdout if proc.returncode == 0 else None
def device_details(device: str) -> dict[str, Any]:
out = _nmcli("-t", "device", "show", device)
info: dict[str, Any] = {"addresses": [], "dns": [], "gateway": "", "hwaddr": "", "state": "", "connection": ""}
if not out:
return info
for line in out.splitlines():
key, _, value = line.partition(":")
value = value.replace("\\:", ":")
base = key.split("[", 1)[0]
if base == "IP4.ADDRESS" and value:
info["addresses"].append(value)
elif base == "IP4.DNS" and value:
info["dns"].append(value)
elif base == "IP4.GATEWAY" and value and value != "--":
info["gateway"] = value
elif base == "GENERAL.HWADDR":
info["hwaddr"] = value
elif base == "GENERAL.STATE":
info["state"] = re.sub(r"^\d+\s*", "", value).strip("() ")
elif base == "GENERAL.CONNECTION" and value != "--":
info["connection"] = value
return info
def profile_settings(interface: str) -> dict[str, Any]:
"""Current settings of our own profile, used to pre-fill the forms."""
out = _nmcli("-t", "-f", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,802-11-wireless.ssid,802-11-wireless.hidden",
"connection", "show", profile_name(interface))
settings = {"exists": False, "method": "dhcp", "address": "", "gateway": "", "dns": "", "ssid": "", "hidden": False}
if not out:
return settings
settings["exists"] = True
for line in out.splitlines():
key, _, value = line.partition(":")
value = value.replace("\\:", ":")
if value == "--":
value = ""
if key == "ipv4.method":
settings["method"] = "static" if value == "manual" else "dhcp"
elif key == "ipv4.addresses":
settings["address"] = value.split(",")[0].strip()
elif key == "ipv4.gateway":
settings["gateway"] = value
elif key == "ipv4.dns":
settings["dns"] = ", ".join(x for x in value.split(",") if x)
elif key == "802-11-wireless.ssid":
settings["ssid"] = value
elif key == "802-11-wireless.hidden":
settings["hidden"] = value == "yes"
return settings
def overview() -> dict[str, Any]:
available = shutil.which("nmcli") is not None
devices = {}
status = _nmcli("-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status") or ""
present = {split_terse(line)[0]: split_terse(line) for line in status.splitlines() if line}
for iface, kind in INTERFACES.items():
fields = present.get(iface)
details = device_details(iface) if fields else {}
state = fields[2] if fields and len(fields) > 2 else ""
devices[iface] = {
"interface": iface,
"kind": kind,
"present": fields is not None,
"state": state,
"state_label": STATE_LABELS.get(state, state or "Niet aanwezig"),
"connected": state == "connected",
"connection": fields[3] if fields and len(fields) > 3 and fields[3] != "--" else "",
"addresses": details.get("addresses", []),
"gateway": details.get("gateway", ""),
"dns": details.get("dns", []),
"hwaddr": details.get("hwaddr", ""),
"profile": profile_settings(iface) if available else {},
}
return {"available": available, "devices": devices, "wifi_country": wifi_country()}
def wifi_country() -> str:
if not shutil.which("iw"):
return ""
try:
out = subprocess.run(["iw", "reg", "get"], capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return ""
match = re.search(r"country ([A-Z0-9]{2}):", out)
return match.group(1) if match and match.group(1) != "00" else ""
def pending_change(pending_file: Path) -> dict[str, Any] | None:
try:
data = json.loads(pending_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
+114
View File
@@ -0,0 +1,114 @@
"""Filesystem locations used by every VoxIP Kiosk component.
The defaults match the installed layout. Unprivileged components may
override them through ``VOXIP_*`` environment variables (used by the test
suite and for local development). The root helper always uses
:func:`default_paths` and never reads the environment.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Paths:
config_dir: Path = Path("/etc/voxip-kiosk")
state_dir: Path = Path("/var/lib/voxip-kiosk")
network_state_dir: Path = Path("/var/lib/voxip-kiosk-network")
agent_run_dir: Path = Path("/run/voxip-kiosk-agent")
browser_run_dir: Path = Path("/run/voxip-kiosk-browser")
health_run_dir: Path = Path("/run/voxip-kiosk-health")
share_dir: Path = Path("/usr/share/voxip-kiosk")
log_dir: Path = Path("/var/log/voxip-kiosk")
helper: Path = Path("/usr/lib/voxip-kiosk/voxip-kiosk-helper")
nm_connections_dir: Path = Path("/etc/NetworkManager/system-connections")
@property
def config_file(self) -> Path:
return self.config_dir / "config.json"
@property
def admin_file(self) -> Path:
return self.config_dir / "admin.json"
@property
def setup_code_file(self) -> Path:
# Lives in the config dir (group kiosk) so the agent can show it on
# the HDMI screen during initial setup.
return self.config_dir / "setup-code"
@property
def tls_dir(self) -> Path:
return self.config_dir / "tls"
@property
def secret_key_file(self) -> Path:
return self.state_dir / "secret_key"
@property
def network_pending_file(self) -> Path:
return self.network_state_dir / "pending.json"
@property
def network_backup_dir(self) -> Path:
return self.network_state_dir / "backup"
@property
def agent_state_file(self) -> Path:
return self.agent_run_dir / "state.json"
@property
def display_info_file(self) -> Path:
return self.browser_run_dir / "display.json"
@property
def health_state_file(self) -> Path:
return self.health_run_dir / "state.json"
@property
def www_dir(self) -> Path:
return self.share_dir / "www"
@property
def version_file(self) -> Path:
return self.share_dir / "VERSION"
@property
def audit_log(self) -> Path:
return self.log_dir / "audit.log"
def default_paths() -> Paths:
return Paths()
_ENV = {
"config_dir": "VOXIP_CONFIG_DIR",
"state_dir": "VOXIP_STATE_DIR",
"network_state_dir": "VOXIP_NETWORK_STATE_DIR",
"agent_run_dir": "VOXIP_AGENT_RUN_DIR",
"browser_run_dir": "VOXIP_BROWSER_RUN_DIR",
"health_run_dir": "VOXIP_HEALTH_RUN_DIR",
"share_dir": "VOXIP_SHARE_DIR",
"log_dir": "VOXIP_LOG_DIR",
"helper": "VOXIP_HELPER",
}
def load_paths() -> Paths:
overrides = {field: Path(os.environ[env]) for field, env in _ENV.items() if os.environ.get(env)}
return Paths(**overrides)
def read_version(paths: Paths) -> str:
try:
return paths.version_file.read_text(encoding="utf-8").strip() or "onbekend"
except OSError:
here = Path(__file__).resolve().parents[2] / "VERSION"
try:
return here.read_text(encoding="utf-8").strip() + "-dev"
except OSError:
return "dev"
+100
View File
@@ -0,0 +1,100 @@
"""Read-only access to systemd unit state and the journal."""
from __future__ import annotations
import subprocess
from typing import Any
BROWSER_UNIT = "voxip-kiosk-browser.service"
X_UNIT = "voxip-kiosk-x.service"
AGENT_UNIT = "voxip-kiosk-agent.service"
MANAGER_UNIT = "voxip-kiosk-manager.service"
HEALTH_UNIT = "voxip-kiosk-healthcheck.service"
# Units whose logs the web interface may show (label → journalctl -u args).
LOG_SOURCES: dict[str, tuple[str, list[str]]] = {
"kiosk": ("Kiosk (Chromium, X, agent)", [BROWSER_UNIT, X_UNIT, AGENT_UNIT]),
"browser": ("Chromium", [BROWSER_UNIT]),
"agent": ("Kiosk-agent", [AGENT_UNIT]),
"health": ("Healthcheck", [HEALTH_UNIT]),
"manager": ("Beheerinterface", [MANAGER_UNIT]),
"network": ("Netwerk", ["NetworkManager.service", "voxip-network-guard.service", "voxip-network-rollback.service", "voxip-network-activate.service"]),
"system": ("Volledig systeem", []),
}
LOG_LINES = (50, 100, 200, 500)
_SHOW_PROPS = "ActiveState,SubState,Result,NRestarts,ActiveEnterTimestamp,ExecMainStatus,LoadState"
def unit_status(unit: str) -> dict[str, str]:
try:
out = subprocess.run(
["systemctl", "show", unit, "--no-pager", "-p", _SHOW_PROPS],
capture_output=True, text=True, timeout=5,
).stdout
except (OSError, subprocess.SubprocessError):
return {}
return parse_show(out)
def parse_show(output: str) -> dict[str, str]:
props = {}
for line in output.splitlines():
key, sep, value = line.partition("=")
if sep:
props[key] = value
return props
def describe(props: dict[str, str]) -> tuple[str, str]:
"""(status, Dutch label) for a unit: status ∈ ok / starting / stopped / error / unknown."""
active = props.get("ActiveState")
if not active:
return "unknown", "Onbekend"
if active == "active":
return "ok", "Actief"
if active in ("activating", "reloading"):
return "starting", "Bezig met starten"
if active == "failed":
return "error", "Fout"
if active == "deactivating":
return "starting", "Bezig met stoppen"
if props.get("Result", "success") not in ("success", ""):
return "error", "Gestopt na fout"
return "stopped", "Gestopt"
def journal(source: str, lines: int = 100, priority: str | None = None) -> str:
if source not in LOG_SOURCES:
raise ValueError("onbekende logbron")
lines = lines if lines in LOG_LINES else 100
cmd = ["journalctl", "--no-pager", "-o", "short-iso", "-n", str(lines)]
for unit in LOG_SOURCES[source][1]:
cmd += ["-u", unit]
if priority in ("err", "warning"):
cmd += ["-p", priority]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError) as exc:
return f"Logs konden niet worden gelezen: {exc}"
return proc.stdout or proc.stderr or "(geen regels)"
def recent_errors(lines: int = 20) -> list[str]:
"""Warnings and errors logged by the kiosk agent, browser launcher and healthcheck."""
cmd = ["journalctl", "--no-pager", "-o", "short-iso", "-n", str(lines), "-p", "warning",
"-u", AGENT_UNIT, "-u", BROWSER_UNIT, "-u", HEALTH_UNIT, "-u", X_UNIT]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return []
return [line for line in out.splitlines() if line and not line.startswith("-- ")]
def summary() -> dict[str, Any]:
result = {}
for key, unit in (("browser", BROWSER_UNIT), ("x", X_UNIT), ("agent", AGENT_UNIT), ("manager", MANAGER_UNIT)):
props = unit_status(unit)
status, label = describe(props)
result[key] = {"unit": unit, "status": status, "label": label, "restarts": props.get("NRestarts", "0"), "since": props.get("ActiveEnterTimestamp", "")}
return result
+42
View File
@@ -0,0 +1,42 @@
"""One-time setup code that guards the initial admin password."""
from __future__ import annotations
import hmac
import os
import secrets
from pathlib import Path
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I confusion on screen
def generate() -> str:
raw = "".join(secrets.choice(ALPHABET) for _ in range(8))
return f"{raw[:4]}-{raw[4:]}"
def normalise(code: str) -> str:
cleaned = "".join(ch for ch in (code or "").upper() if ch.isalnum())
return f"{cleaned[:4]}-{cleaned[4:]}" if len(cleaned) == 8 else cleaned
def write(path: Path, code: str) -> None:
"""Store the code readable for group kiosk (the setup screen) only."""
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(code + "\n")
os.chmod(path, 0o640)
def read(path: Path) -> str | None:
try:
return path.read_text(encoding="utf-8").strip() or None
except OSError:
return None
def matches(path: Path, candidate: str) -> bool:
stored = read(path)
if not stored:
return False
return hmac.compare_digest(stored.encode(), normalise(candidate).encode())
+201
View File
@@ -0,0 +1,201 @@
"""System metrics for the dashboard, read without any privileges."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
_cpu_lock = threading.Lock()
_cpu_prev: tuple[int, int] | None = None
def _read(path: str) -> str | None:
try:
return Path(path).read_text(encoding="utf-8")
except OSError:
return None
def uptime_seconds(proc: str = "/proc") -> float | None:
raw = _read(f"{proc}/uptime")
try:
return float(raw.split()[0]) if raw else None
except (ValueError, IndexError):
return None
def load_average() -> tuple[float, float, float] | None:
try:
return os.getloadavg()
except OSError:
return None
def _cpu_times(proc: str) -> tuple[int, int] | None:
raw = _read(f"{proc}/stat")
if not raw:
return None
fields = raw.splitlines()[0].split()
if not fields or fields[0] != "cpu":
return None
values = [int(x) for x in fields[1:]]
idle = values[3] + (values[4] if len(values) > 4 else 0)
return sum(values), idle
def cpu_percent(proc: str = "/proc") -> float | None:
"""CPU usage since the previous call (first call: since boot)."""
global _cpu_prev
now = _cpu_times(proc)
if now is None:
return None
with _cpu_lock:
prev, _cpu_prev = _cpu_prev, now
total, idle = now
if prev is not None and total > prev[0]:
total, idle = total - prev[0], idle - prev[1]
if total <= 0:
return None
return round(100.0 * (1 - idle / total), 1)
def temperature_c(sys: str = "/sys") -> float | None:
raw = _read(f"{sys}/class/thermal/thermal_zone0/temp")
try:
return round(int(raw.strip()) / 1000, 1) if raw else None
except ValueError:
return None
def memory(proc: str = "/proc") -> dict[str, Any] | None:
raw = _read(f"{proc}/meminfo")
if not raw:
return None
info = {}
for line in raw.splitlines():
key, _, rest = line.partition(":")
try:
info[key] = int(rest.split()[0]) * 1024
except (ValueError, IndexError):
continue
total = info.get("MemTotal")
avail = info.get("MemAvailable")
if not total or avail is None:
return None
used = total - avail
return {"total": total, "used": used, "percent": round(100 * used / total, 1)}
def disk(path: str = "/") -> dict[str, Any] | None:
try:
usage = shutil.disk_usage(path)
except OSError:
return None
return {"total": usage.total, "used": usage.used, "percent": round(100 * usage.used / usage.total, 1)}
THROTTLE_FLAGS = {
0: "Onderspanning",
1: "Frequentie begrensd",
2: "Afgeknepen (throttled)",
3: "Temperatuurlimiet",
16: "Onderspanning opgetreden",
17: "Frequentiebegrenzing opgetreden",
18: "Afknijpen opgetreden",
19: "Temperatuurlimiet opgetreden",
}
def parse_throttled(output: str) -> dict[str, Any] | None:
_, _, value = output.strip().partition("=")
try:
bits = int(value, 16)
except ValueError:
return None
flags = [label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << bit)]
return {"raw": hex(bits), "now": bool(bits & 0xF), "ever": bool(bits & 0xF0000), "flags": flags}
def throttled() -> dict[str, Any] | None:
"""Power/thermal throttling as reported by the firmware (needs group video)."""
if not shutil.which("vcgencmd"):
return None
try:
out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True, text=True, timeout=3, check=True).stdout
except (OSError, subprocess.SubprocessError):
return None
return parse_throttled(out)
def ip_addresses() -> dict[str, list[str]]:
"""Global addresses per interface, e.g. ``{"eth0": ["192.168.1.20/24"]}``."""
try:
out = subprocess.run(["ip", "-j", "addr", "show"], capture_output=True, text=True, timeout=3, check=True).stdout
data = json.loads(out)
except (OSError, subprocess.SubprocessError, ValueError):
return {}
return parse_ip_json(data)
def parse_ip_json(data: list[dict[str, Any]]) -> dict[str, list[str]]:
result: dict[str, list[str]] = {}
for iface in data:
name = iface.get("ifname", "")
if name == "lo":
continue
addrs = [
f"{a['local']}/{a['prefixlen']}"
for a in iface.get("addr_info", [])
if a.get("scope") == "global" and "local" in a
]
if addrs:
result[name] = addrs
return result
def os_release() -> str:
raw = _read("/etc/os-release") or ""
for line in raw.splitlines():
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
return "onbekend"
def model() -> str:
raw = _read("/proc/device-tree/model")
return raw.strip("\x00\n ") if raw else "onbekend"
def format_duration(seconds: float | None) -> str:
if seconds is None:
return "–"
seconds = int(seconds)
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if days:
return f"{days} d {hours} u {minutes} min"
if hours:
return f"{hours} u {minutes} min"
return f"{minutes} min"
def format_bytes(value: int | None) -> str:
if value is None:
return "–"
size = float(value)
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.0f} {unit}" if unit in ("B", "KB") else f"{size:.1f} {unit}".replace(".", ",")
size /= 1024
return f"{value} B"
def timestamp() -> float:
return time.time()
+195
View File
@@ -0,0 +1,195 @@
"""Input validation shared by the web interface, the config loader and the
root helper. Every validator either returns a normalised value or raises
:class:`ValidationError` with a Dutch message suitable for the UI.
"""
from __future__ import annotations
import ipaddress
import re
from urllib.parse import unquote_plus, urlsplit, urlunsplit
class ValidationError(ValueError):
pass
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_HOSTNAME = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
_RESOLUTION = re.compile(r"^(\d{3,4})x(\d{3,4})(?:@(\d{2,3}(?:\.\d{1,3})?))?$")
_COUNTRY = re.compile(r"^[A-Z]{2}$")
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
# Query parameters whose values are secrets (InfoFlow display URLs carry
# their access token as ?token=...). They are masked wherever a URL is shown
# or logged.
SECRET_PARAMS = {"token", "key", "apikey", "api_key", "secret", "password", "auth", "access_token"}
def kiosk_url(value: str) -> str:
"""A kiosk URL: HTTPS only, with a host, without embedded credentials."""
if not isinstance(value, str):
raise ValidationError("Ongeldige URL.")
url = value.strip()
if not url:
raise ValidationError("Vul een URL in.")
if len(url) > 2048:
raise ValidationError("De URL is te lang (maximaal 2048 tekens).")
if _CONTROL.search(url) or " " in url:
raise ValidationError("De URL mag geen spaties of stuurtekens bevatten.")
try:
parts = urlsplit(url)
port = parts.port
except ValueError:
raise ValidationError("De URL is ongeldig.") from None
if parts.scheme.lower() != "https":
raise ValidationError("Alleen HTTPS-URL's zijn toegestaan (https://…).")
if not parts.hostname:
raise ValidationError("De URL bevat geen hostnaam.")
if parts.username or parts.password:
raise ValidationError("Gebruikersnaam/wachtwoord in de URL is niet toegestaan.")
if port is not None and not 0 < port < 65536:
raise ValidationError("Ongeldige poort in de URL.")
host = parts.hostname
try:
ipaddress.ip_address(host)
except ValueError:
if not all(_HOSTNAME.match(label) for label in host.lower().rstrip(".").split(".")):
raise ValidationError("De hostnaam in de URL is ongeldig.") from None
return url
def mask_url(url: str) -> str:
"""Hide secret query parameter values: ``?token=abcdef1234`` → ``?token=…1234``."""
try:
parts = urlsplit(url)
except ValueError:
return url
if not parts.query:
return url
pairs = []
for pair in parts.query.split("&"):
key, sep, val = pair.partition("=")
if sep and val and unquote_plus(key).lower() in SECRET_PARAMS:
val = "…" + val[-4:] if len(val) > 8 else "…"
pairs.append(key + sep + val)
return urlunsplit(parts._replace(query="&".join(pairs)))
def hostname(value: str) -> str:
name = (value or "").strip().lower()
if not _HOSTNAME.match(name):
raise ValidationError(
"Een hostname bestaat uit 1–63 tekens: kleine letters, cijfers en "
"koppeltekens, niet beginnend of eindigend met een koppelteken."
)
if name == "localhost":
raise ValidationError("'localhost' kan niet als hostname worden gebruikt.")
return name
def device_name(value: str) -> str:
name = (value or "").strip()
if not name:
raise ValidationError("Vul een apparaatnaam in.")
if len(name) > 64 or _CONTROL.search(name):
raise ValidationError("De apparaatnaam mag maximaal 64 tekens bevatten, zonder stuurtekens.")
return name
def resolution(value: str) -> str:
val = (value or "").strip().lower()
if val == "auto":
return val
match = _RESOLUTION.match(val)
if not match:
raise ValidationError("Gebruik 'auto' of een resolutie als 1920x1080 of 1920x1080@60.")
width, height = int(match.group(1)), int(match.group(2))
if not (320 <= width <= 4096 and 200 <= height <= 4096):
raise ValidationError("Resolutie buiten het ondersteunde bereik.")
return val
def parse_resolution(value: str) -> tuple[str, str | None] | None:
"""``"1920x1080@60"`` → ``("1920x1080", "60")``; ``"auto"`` → ``None``."""
if value == "auto":
return None
match = _RESOLUTION.match(value)
if not match:
return None
return f"{match.group(1)}x{match.group(2)}", match.group(3)
def ipv4_address(value: str) -> str:
try:
addr = ipaddress.IPv4Address((value or "").strip())
except ValueError:
raise ValidationError(f"'{value}' is geen geldig IPv4-adres.") from None
if addr.is_multicast or addr.is_unspecified or addr.is_loopback:
raise ValidationError(f"'{value}' kan hier niet gebruikt worden.")
return str(addr)
def ipv4_interface(value: str) -> str:
"""``192.168.1.50/24`` (a host address with prefix length)."""
raw = (value or "").strip()
if "/" not in raw:
raise ValidationError("Vul het IP-adres in met prefixlengte, bijvoorbeeld 192.168.1.50/24.")
try:
iface = ipaddress.IPv4Interface(raw)
except ValueError:
raise ValidationError(f"'{raw}' is geen geldig IPv4-adres met prefix.") from None
if not 8 <= iface.network.prefixlen <= 32:
raise ValidationError("De prefixlengte moet tussen 8 en 32 liggen.")
if iface.network.prefixlen < 31 and iface.ip in (iface.network.network_address, iface.network.broadcast_address):
raise ValidationError("Het adres is een netwerk- of broadcastadres.")
ipv4_address(str(iface.ip))
return iface.with_prefixlen
def dns_servers(values: list[str] | str) -> list[str]:
if isinstance(values, str):
values = re.split(r"[\s,;]+", values)
result: list[str] = []
for raw in values:
raw = raw.strip()
if not raw:
continue
try:
addr = ipaddress.ip_address(raw)
except ValueError:
raise ValidationError(f"'{raw}' is geen geldig DNS-serveradres.") from None
if str(addr) not in result:
result.append(str(addr))
if len(result) > 3:
raise ValidationError("Maximaal drie DNS-servers.")
return result
def ssid(value: str) -> str:
val = value or ""
if not val:
raise ValidationError("Vul een netwerknaam (SSID) in.")
if len(val.encode("utf-8")) > 32:
raise ValidationError("Een SSID is maximaal 32 bytes.")
if _CONTROL.search(val):
raise ValidationError("De SSID bevat ongeldige tekens.")
return val
def wifi_psk(value: str) -> str:
val = value or ""
if _HEX64.match(val):
return val
if not 8 <= len(val) <= 63:
raise ValidationError("Een wifi-wachtwoord is 8 tot 63 tekens (of 64 hexadecimale tekens).")
if any(not (32 <= ord(ch) <= 126) for ch in val):
raise ValidationError("Het wifi-wachtwoord mag alleen gewone ASCII-tekens bevatten.")
return val
def country_code(value: str) -> str:
val = (value or "").strip().upper()
if not _COUNTRY.match(val):
raise ValidationError("Gebruik een landcode van twee letters, bijvoorbeeld NL.")
return val
+73
View File
@@ -0,0 +1,73 @@
"""VoxIP Kiosk Manager — Flask application factory."""
from __future__ import annotations
import os
import secrets
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from flask import Flask
from ..cdp import CDP
from ..config import load_config
from ..helper_client import HelperClient
from ..paths import Paths, load_paths, read_version
from .auth import CredentialStore, LoginThrottle
@dataclass
class Services:
paths: Paths
credentials: CredentialStore
throttle: LoginThrottle
helper: Any
cdp: Any
version: str
def _secret_key(paths: Paths) -> bytes:
path = paths.secret_key_file
try:
key = path.read_bytes()
if len(key) >= 32:
return key
except FileNotFoundError:
pass
key = secrets.token_bytes(64)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as fh:
fh.write(key)
return key
def create_app(paths: Paths | None = None, *, helper: Any = None, cdp: Any = None) -> Flask:
paths = paths or load_paths()
config = load_config(paths.config_file)
app = Flask(__name__)
app.secret_key = _secret_key(paths)
app.config.update(
SESSION_COOKIE_NAME="voxip_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Strict",
SESSION_COOKIE_SECURE=config["admin_https"],
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
MAX_CONTENT_LENGTH=64 * 1024,
TEMPLATES_AUTO_RELOAD=False,
)
credentials = CredentialStore(paths.admin_file, paths.setup_code_file)
credentials.ensure_setup_code()
app.extensions["voxip"] = Services(
paths=paths,
credentials=credentials,
throttle=LoginThrottle(),
helper=helper or HelperClient(paths.helper),
cdp=cdp or CDP(timeout=5),
version=read_version(paths),
)
from .views import bp
app.register_blueprint(bp)
return app
+156
View File
@@ -0,0 +1,156 @@
"""Admin credentials (Argon2id) and login throttling."""
from __future__ import annotations
import json
import secrets
import threading
import time
from collections import deque
from pathlib import Path
from typing import Any
from argon2 import PasswordHasher, Type
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
from .. import setupcode
from ..config import atomic_write_json
# 64 MiB / 3 passes / 2 lanes: ~0.5 s on a Raspberry Pi 3, fine for a single admin.
HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16, type=Type.ID)
MIN_LENGTH = 10
MAX_LENGTH = 128
WEAK = {"admin", "administrator", "password", "wachtwoord", "raspberry", "voxip", "kiosk", "welkom", "welcome", "qwerty", "123456"}
def password_problem(password: str, confirm: str | None = None) -> str | None:
if confirm is not None and password != confirm:
return "De wachtwoorden komen niet overeen."
if len(password) < MIN_LENGTH:
return f"Gebruik minimaal {MIN_LENGTH} tekens."
if len(password) > MAX_LENGTH:
return f"Gebruik maximaal {MAX_LENGTH} tekens."
remainder = password.lower()
for word in WEAK:
remainder = remainder.replace(word, "")
if len(set(password)) < 4 or len(remainder) < 6 or remainder.isdigit():
return "Dit wachtwoord is te eenvoudig. Kies een uniek wachtwoord."
return None
class CredentialStore:
def __init__(self, admin_file: Path, setup_code_file: Path):
self.admin_file = admin_file
self.setup_code_file = setup_code_file
self._lock = threading.Lock()
def _load(self) -> dict[str, Any] | None:
try:
data = json.loads(self.admin_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not str(data.get("password_hash", "")).startswith("$argon2id$"):
return None
return data
def is_configured(self) -> bool:
return self._load() is not None
def ensure_setup_code(self) -> str | None:
"""Create a setup code while no password exists; remove it afterwards."""
if self.is_configured():
self.setup_code_file.unlink(missing_ok=True)
return None
code = setupcode.read(self.setup_code_file)
if not code:
code = setupcode.generate()
setupcode.write(self.setup_code_file, code)
return code
def check_setup_code(self, candidate: str) -> bool:
return setupcode.matches(self.setup_code_file, candidate)
def set_password(self, password: str) -> str:
"""Store a new password hash; returns the new session epoch (old sessions become invalid)."""
epoch = secrets.token_hex(16)
record = {
"password_hash": HASHER.hash(password),
"session_epoch": epoch,
"updated_at": int(time.time()),
}
with self._lock:
atomic_write_json(self.admin_file, record, mode=0o600)
self.setup_code_file.unlink(missing_ok=True)
return epoch
def verify(self, password: str) -> bool:
data = self._load()
if data is None or len(password) > MAX_LENGTH:
return False
try:
HASHER.verify(data["password_hash"], password)
except (VerifyMismatchError, VerificationError, InvalidHash):
return False
if HASHER.check_needs_rehash(data["password_hash"]):
data["password_hash"] = HASHER.hash(password)
with self._lock:
atomic_write_json(self.admin_file, data, mode=0o600)
return True
def session_epoch(self) -> str | None:
data = self._load()
return data.get("session_epoch") if data else None
class LoginThrottle:
"""Per-IP exponential lockout plus a global brake against distributed guessing."""
def __init__(self, free_attempts: int = 5, window: float = 900, max_lock: float = 900,
global_limit: int = 30, global_window: float = 300, clock=time.monotonic):
self.free_attempts = free_attempts
self.window = window
self.max_lock = max_lock
self.global_limit = global_limit
self.global_window = global_window
self.clock = clock
self._lock = threading.Lock()
self._failures: dict[str, deque] = {}
self._locked_until: dict[str, float] = {}
self._global: deque = deque()
def _prune(self, now: float) -> None:
while self._global and now - self._global[0] > self.global_window:
self._global.popleft()
for ip in list(self._failures):
q = self._failures[ip]
while q and now - q[0] > self.window:
q.popleft()
if not q and self._locked_until.get(ip, 0) <= now:
self._failures.pop(ip, None)
self._locked_until.pop(ip, None)
def retry_after(self, ip: str) -> int:
with self._lock:
now = self.clock()
self._prune(now)
wait = self._locked_until.get(ip, 0) - now
if len(self._global) >= self.global_limit:
wait = max(wait, self.global_window - (now - self._global[0]))
return max(0, int(wait + 0.999))
def failure(self, ip: str) -> None:
with self._lock:
now = self.clock()
self._prune(now)
q = self._failures.setdefault(ip, deque())
q.append(now)
self._global.append(now)
excess = len(q) - self.free_attempts
if excess >= 0:
self._locked_until[ip] = now + min(self.max_lock, 30 * (2 ** excess))
def success(self, ip: str) -> None:
with self._lock:
self._failures.pop(ip, None)
self._locked_until.pop(ip, None)
+90
View File
@@ -0,0 +1,90 @@
"""Request-level protections: client network filter, CSRF, security headers."""
from __future__ import annotations
import hmac
import ipaddress
import secrets
from urllib.parse import urlsplit
from flask import Request, session
CSRF_FIELD = "csrf_token"
CSRF_HEADER = "X-CSRF-Token"
_VPN_RANGES = (ipaddress.ip_network("100.64.0.0/10"),) # CGNAT, used by Tailscale and many VPNs
CSP = "; ".join([
"default-src 'self'",
"img-src 'self' data: blob:",
"style-src 'self'",
"script-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'none'",
"object-src 'none'",
])
def client_allowed(remote_addr: str | None, allow_public: bool, local_networks: list[str] = ()) -> bool:
"""Only local networks may reach the admin interface unless explicitly opened up.
Local means: private/link-local/loopback ranges, CGNAT (VPN) or one of
the subnets this device itself is on (covers global IPv6 prefixes on
the LAN).
"""
if allow_public:
return True
try:
addr = ipaddress.ip_address((remote_addr or "").split("%", 1)[0])
except ValueError:
return False
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
addr = addr.ipv4_mapped
if addr.is_multicast:
return False
if addr.is_loopback or addr.is_link_local or addr.is_private:
return True
if any(addr in net for net in _VPN_RANGES if addr.version == net.version):
return True
for cidr in local_networks:
try:
net = ipaddress.ip_interface(cidr).network
except ValueError:
continue
if net.version == addr.version and addr in net and net.prefixlen >= (16 if net.version == 4 else 48):
return True
return False
def csrf_token() -> str:
token = session.get("csrf")
if not token:
token = secrets.token_urlsafe(32)
session["csrf"] = token
return token
def csrf_valid(request: Request) -> bool:
expected = session.get("csrf")
supplied = request.headers.get(CSRF_HEADER) or request.form.get(CSRF_FIELD, "")
if not expected or not supplied or not hmac.compare_digest(expected, supplied):
return False
origin = request.headers.get("Origin")
if origin and origin != "null":
return urlsplit(origin).netloc == request.host
return True
def apply_headers(response, secure: bool):
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
if response.mimetype in ("text/html", "application/json") or response.mimetype.startswith("image/"):
response.headers["Cache-Control"] = "no-store"
if secure:
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response
+155
View File
@@ -0,0 +1,155 @@
:root {
--navy: #0b2545;
--navy-2: #13315c;
--accent: #1d6fd8;
--accent-dark: #1559b0;
--bg: #f2f5f9;
--card: #ffffff;
--text: #1b2433;
--muted: #5c6b80;
--border: #dde3eb;
--ok: #1f9d55;
--warning: #d98a00;
--error: #d64545;
--stopped: #8592a3;
--starting: #2f80ed;
--radius: 12px;
--shadow: 0 1px 2px rgba(11, 37, 69, .06), 0 4px 16px rgba(11, 37, 69, .06);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--bg);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); line-height: 1.5; }
a { color: var(--accent); }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; background: #eef2f7; padding: .05em .35em; border-radius: 4px; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h2 { font-size: 1.1rem; margin: 0 0 .9rem; }
.mt { margin-top: 1.6rem; }
.muted { color: var(--muted); }
.small { font-size: .875rem; }
.break { overflow-wrap: anywhere; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* --- top bar --------------------------------------------------------------- */
.topbar { background: var(--navy); color: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, .15); position: sticky; top: 0; z-index: 10; }
.topbar-inner { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; align-items: center; gap: 1rem; min-height: 60px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: .6rem; color: #fff; text-decoration: none; font-size: 1.1rem; margin-right: auto; }
.mainnav { display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
.mainnav a, .mainnav button { color: #d6e2f3; text-decoration: none; padding: .5rem .75rem; border-radius: 8px; font: inherit; font-size: .95rem; background: none; border: 0; cursor: pointer; }
.mainnav a:hover, .mainnav button:hover { background: var(--navy-2); color: #fff; }
.mainnav a[aria-current="page"] { background: var(--navy-2); color: #fff; box-shadow: inset 0 -3px 0 #4f9cf9; }
.nav-logout { margin: 0; }
.nav-toggle { display: none; background: none; border: 1px solid rgba(255, 255, 255, .3); border-radius: 8px; width: 44px; height: 40px; cursor: pointer; }
.nav-toggle .bars, .nav-toggle .bars::before, .nav-toggle .bars::after { display: block; width: 20px; height: 2px; background: #fff; margin: 0 auto; position: relative; content: ""; }
.nav-toggle .bars::before { position: absolute; top: -6px; }
.nav-toggle .bars::after { position: absolute; top: 6px; }
@media (max-width: 860px) {
.nav-toggle { display: block; }
.mainnav { display: none; width: 100%; flex-direction: column; align-items: stretch; padding-bottom: .75rem; }
.mainnav.open { display: flex; }
.mainnav a, .mainnav button { padding: .75rem; text-align: left; width: 100%; }
}
/* --- layout ------------------------------------------------------------------ */
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 1.5rem 1rem 2rem; flex: 1; }
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.page-head p { margin: 0; }
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-bottom: 1rem; }
.grid-2 { grid-template-columns: repeat(auto-fill, minmax(min(100%, 460px), 1fr)); }
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; box-shadow: var(--shadow); margin-bottom: 1rem; min-width: 0; }
.grid > .card { margin-bottom: 0; }
.card-narrow { max-width: 460px; margin: 2rem auto; }
.card-pending { border-color: var(--warning); background: #fff8eb; }
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .5rem; padding: 1rem; color: var(--muted); font-size: .85rem; max-width: 1200px; width: 100%; margin: 0 auto; }
/* --- key/value lists ---------------------------------------------------------- */
.kv { display: grid; grid-template-columns: minmax(110px, 38%) 1fr; gap: .55rem 1rem; margin: 0; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; min-width: 0; }
.kv-tight { margin-top: 1rem; font-size: .92rem; }
.note { margin: 1rem 0 0; padding: .6rem .8rem; background: #fff4e5; border-radius: 8px; font-size: .9rem; }
/* --- status badges ------------------------------------------------------------- */
.badge { display: inline-flex; align-items: center; gap: .45rem; padding: .2rem .7rem; border-radius: 999px; font-size: .9rem; font-weight: 600; background: #eef2f7; }
.badge .dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--stopped); flex: none; }
.badge-ok { background: #e3f5ea; color: #146c3b; } .badge-ok .dot { background: var(--ok); }
.badge-warning { background: #fff1d6; color: #8a5800; } .badge-warning .dot { background: var(--warning); }
.badge-error { background: #fde6e6; color: #a32c2c; } .badge-error .dot { background: var(--error); }
.badge-starting { background: #e3eefc; color: #1d4f9c; } .badge-starting .dot { background: var(--starting); animation: pulse 1.2s infinite; }
.badge-stopped, .badge-unknown { color: #4a5668; }
@keyframes pulse { 50% { opacity: .35; } }
.meter { height: 6px; border-radius: 3px; background: #e6ebf2; margin-top: .35rem; overflow: hidden; }
.meter span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .4s; }
.meter span.is-mid { background: var(--warning); }
.meter span.is-high { background: var(--error); }
/* --- buttons ---------------------------------------------------------------------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; font: inherit; font-weight: 600; padding: .6rem 1.1rem; border-radius: 10px; border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background .15s, transform .05s; min-height: 44px; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .6; cursor: progress; }
.btn-primary { background: var(--accent); color: #fff; } .btn-primary:hover { background: var(--accent-dark); }
.btn-secondary { background: #fff; color: var(--navy); border-color: var(--border); } .btn-secondary:hover { background: #f1f4f8; }
.btn-warning { background: #f0a020; color: #2b1d00; } .btn-warning:hover { background: #dd9010; }
.btn-danger { background: var(--error); color: #fff; } .btn-danger:hover { background: #b93636; }
.btn-success { background: var(--ok); color: #fff; } .btn-success:hover { background: #17834a; }
.btn-dark { background: var(--navy); color: #fff; } .btn-dark:hover { background: var(--navy-2); }
.btn-big { font-size: 1.05rem; padding: 1rem 1.5rem; min-height: 64px; min-width: 220px; border-radius: 14px; box-shadow: var(--shadow); }
.btn-block { width: 100%; }
.actions-row { display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: 1rem; }
.inline-form { margin: 0; }
@media (max-width: 560px) {
.actions-row .inline-form, .actions-row .btn-big { width: 100%; }
}
/* --- forms -------------------------------------------------------------------------- */
.form { display: flex; flex-direction: column; gap: .9rem; }
.form label { display: flex; flex-direction: column; gap: .3rem; font-weight: 600; font-size: .95rem; }
.form small { font-weight: 400; color: var(--muted); }
.form input[type=text], .form input[type=url], .form input[type=password], .form input[type=number], .form select {
font: inherit; font-weight: 400; padding: .6rem .7rem; border: 1px solid #c5cfdb; border-radius: 8px; background: #fff; color: var(--text); width: 100%; min-height: 44px;
}
.form input:focus, .form select:focus { outline: 2px solid #8ab8f5; outline-offset: 1px; border-color: var(--accent); }
.form label.check { flex-direction: row; align-items: center; gap: .55rem; font-weight: 400; }
.form label.check input { width: 1.15rem; height: 1.15rem; }
.form .btn { align-self: flex-start; }
.form .btn-block { align-self: stretch; }
.form-inline { flex-direction: row; flex-wrap: wrap; align-items: flex-end; }
.form-inline label { min-width: 150px; }
.input-short { max-width: 6rem; text-transform: uppercase; }
.input-code { text-transform: uppercase; letter-spacing: .15em; font-family: ui-monospace, Menlo, monospace !important; }
.input-row { display: flex; gap: .5rem; }
.input-row input { flex: 1; }
.radio-row { border: 0; padding: 0; margin: 0; display: flex; gap: 1.5rem; flex-wrap: wrap; }
.radio-row legend { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; padding: 0; }
.static-fields { display: flex; flex-direction: column; gap: .9rem; }
.static-fields[hidden] { display: none; }
.scan-results { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; max-height: 240px; overflow: auto; }
.scan-results li button { width: 100%; display: flex; justify-content: space-between; gap: 1rem; padding: .55rem .75rem; background: none; border: 0; border-bottom: 1px solid var(--border); font: inherit; cursor: pointer; text-align: left; }
.scan-results li button:hover { background: #f1f4f8; }
.plain { margin: 0; padding-left: 1.2rem; }
.plain li { margin-bottom: .4rem; }
/* --- alerts --------------------------------------------------------------------------- */
.alert { padding: .8rem 1rem; border-radius: 10px; margin-bottom: 1rem; border: 1px solid; }
.alert-success { background: #e3f5ea; border-color: #b6e2c6; color: #145c33; }
.alert-error { background: #fde6e6; border-color: #f4bcbc; color: #8f2424; }
.alert-warning { background: #fff1d6; border-color: #f3d39b; color: #7a4e00; }
.alert-info { background: #e3eefc; border-color: #bcd4f7; color: #1d4f9c; }
/* --- logs & screenshot ----------------------------------------------------------------- */
.log { background: #0f1b2d; color: #d7e3f4; padding: 1rem; border-radius: 10px; font-size: .8rem; line-height: 1.45; overflow: auto; max-height: 65vh; white-space: pre-wrap; word-break: break-word; margin: 1rem 0 0; }
.log-short { max-height: 280px; margin-top: 0; }
.screenshot { background: #0f1b2d; border-radius: 10px; min-height: 160px; display: flex; align-items: center; justify-content: center; margin-bottom: .8rem; overflow: hidden; }
.screenshot p { color: #b9c7da; padding: 1rem; text-align: center; margin: 0; }
.screenshot img { width: 100%; height: auto; display: block; }
/* --- dialog ------------------------------------------------------------------------------ */
.dialog { border: 0; border-radius: var(--radius); padding: 1.5rem; max-width: min(440px, calc(100vw - 2rem)); box-shadow: 0 20px 60px rgba(0, 0, 0, .3); }
.dialog::backdrop { background: rgba(11, 37, 69, .55); }
.dialog form { margin: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.25rem; flex-wrap: wrap; }
+206
View File
@@ -0,0 +1,206 @@
/* VoxIP Kiosk Manager — progressive enhancements. Every page works without JS. */
(function () {
"use strict";
const csrf = () => document.querySelector('meta[name="csrf-token"]').content;
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// Mobile navigation
const toggle = $("[data-nav-toggle]");
if (toggle) {
toggle.addEventListener("click", () => {
const nav = $("#mainnav");
const open = nav.classList.toggle("open");
toggle.setAttribute("aria-expanded", String(open));
});
}
// Confirmation dialog for dangerous actions (forms with data-confirm)
const dialog = $("#confirm-dialog");
$$("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", (event) => {
if (form.dataset.confirmed === "1" || !dialog || typeof dialog.showModal !== "function") return;
event.preventDefault();
$("[data-confirm-title]", dialog).textContent = form.dataset.confirmTitle || "Weet je het zeker?";
$("[data-confirm-text]", dialog).textContent = form.dataset.confirm;
dialog.returnValue = "";
dialog.showModal();
dialog.addEventListener("close", function onClose() {
dialog.removeEventListener("close", onClose);
if (dialog.returnValue === "ok") {
form.dataset.confirmed = "1";
form.requestSubmit ? form.requestSubmit() : form.submit();
}
});
});
});
// Disable buttons while a form submits, to prevent double actions
$$("form").forEach((form) => {
form.addEventListener("submit", (event) => {
if (event.defaultPrevented || form.method.toLowerCase() !== "post") return;
setTimeout(() => $$("button[type=submit], button:not([type])", form).forEach((b) => (b.disabled = true)), 0);
});
});
// Meters (width set here because the CSP forbids inline styles)
function setMeter(el, pct) {
const value = Math.max(0, Math.min(100, Number(pct) || 0));
el.style.width = value + "%";
el.classList.toggle("is-high", value >= 90);
el.classList.toggle("is-mid", value >= 75 && value < 90);
}
$$("[data-meter]").forEach((el) => setMeter(el, el.dataset.pct));
// Local time for epoch timestamps
$$("time[data-epoch]").forEach((el) => {
const d = new Date(Number(el.dataset.epoch) * 1000);
el.dateTime = d.toISOString();
el.textContent = d.toLocaleString("nl-NL", { dateStyle: "short", timeStyle: "medium" });
});
// Live dashboard
const live = $("[data-live-status]");
if (live) {
const get = (obj, path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), obj);
const refresh = async () => {
try {
const res = await fetch(live.dataset.liveStatus, { headers: { Accept: "application/json" }, cache: "no-store" });
if (res.status === 401) return location.reload();
if (!res.ok) return;
const data = await res.json();
$$("[data-field]").forEach((el) => {
const value = get(data, el.dataset.field);
if (value !== undefined && value !== null) el.textContent = value;
});
$$("[data-meter]").forEach((el) => setMeter(el, data[el.dataset.meter]));
$$("[data-badge]").forEach((el) => {
const key = el.dataset.badge;
let status, label;
if (key === "temperature") { status = data.temperature_status; label = data.temperature; }
else if (data[key]) { status = data[key].status; label = data[key].label; }
if (!status) return;
el.className = "badge badge-" + status;
$("[data-badge-label]", el).textContent = label;
});
} catch (e) { /* device rebooting or offline: try again next round */ }
};
setInterval(refresh, 5000);
}
// Screenshot
const shotBtn = $("[data-screenshot-btn]");
if (shotBtn) {
const box = $("[data-screenshot]");
const img = $("img", box);
const msg = $("[data-screenshot-msg]", box);
shotBtn.addEventListener("click", async () => {
shotBtn.disabled = true;
msg.textContent = "Screenshot maken…";
msg.hidden = false;
try {
const res = await fetch(box.dataset.screenshot + "?t=" + Date.now(), { cache: "no-store" });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
if (img.src) URL.revokeObjectURL(img.src);
img.src = URL.createObjectURL(blob);
img.hidden = false;
msg.hidden = true;
} catch (e) {
img.hidden = true;
msg.textContent = e.message || "Screenshot niet beschikbaar.";
} finally {
shotBtn.disabled = false;
}
});
}
// Static IP fields only when "Statisch" is selected
$$("form[data-ipform]").forEach((form) => {
const fields = $("[data-static-fields]", form);
const update = () => {
const method = $('input[name="method"]:checked', form);
fields.hidden = !method || method.value !== "static";
};
$$('input[name="method"]', form).forEach((r) => r.addEventListener("change", update));
update();
});
// "Scherm altijd aan" hides the timeout field
$$("[data-toggle-target]").forEach((box) => {
const target = $(box.dataset.toggleTarget);
const update = () => { if (target) target.hidden = box.checked; };
box.addEventListener("change", update);
update();
});
// Wifi scan
const scanBtn = $("[data-wifi-scan]");
if (scanBtn) {
const form = scanBtn.closest("form");
const list = $("[data-scan-results]", form);
const datalist = $("#wifi-networks");
scanBtn.addEventListener("click", async () => {
scanBtn.disabled = true;
scanBtn.textContent = "Scannen…";
list.hidden = false;
list.replaceChildren(Object.assign(document.createElement("li"), { textContent: "Netwerken zoeken…", className: "muted" }));
try {
const res = await fetch(scanBtn.dataset.wifiScan, { method: "POST", headers: { "X-CSRF-Token": csrf(), Accept: "application/json" } });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Scannen mislukt");
list.replaceChildren();
datalist.replaceChildren();
if (!data.networks.length) list.append(Object.assign(document.createElement("li"), { textContent: "Geen netwerken gevonden.", className: "muted" }));
data.networks.forEach((n) => {
const li = document.createElement("li");
const b = document.createElement("button");
b.type = "button";
const name = document.createElement("span");
name.textContent = n.ssid + (n.in_use ? " (verbonden)" : "");
const meta = document.createElement("span");
meta.className = "muted";
meta.textContent = n.signal + "% · " + (n.security === "open" ? "open" : n.security);
b.append(name, meta);
b.addEventListener("click", () => {
$('input[name="ssid"]', form).value = n.ssid;
$('input[name="open"]', form).checked = n.security === "open";
list.hidden = true;
$('input[name="psk"]', form).focus();
});
li.append(b);
list.append(li);
datalist.append(Object.assign(document.createElement("option"), { value: n.ssid }));
});
} catch (e) {
list.replaceChildren(Object.assign(document.createElement("li"), { textContent: e.message, className: "muted" }));
} finally {
scanBtn.disabled = false;
scanBtn.textContent = "Scannen";
}
});
}
// Pending network change countdown
const pending = $("[data-pending]");
if (pending) {
let remaining = Number(pending.dataset.remaining) || 0;
const counter = $("[data-countdown]", pending);
const tick = setInterval(() => {
remaining = Math.max(0, remaining - 1);
counter.textContent = remaining;
if (remaining === 0) {
clearInterval(tick);
setTimeout(() => location.reload(), 5000);
}
}, 1000);
}
// Redirect after the admin interface moved
const moved = $("[data-redirect]");
if (moved) setTimeout(() => (location.href = moved.dataset.redirect), Number(moved.dataset.redirectAfter) * 1000);
// Scroll log views to the newest line
$$("[data-scroll-bottom]").forEach((el) => (el.scrollTop = el.scrollHeight));
})();
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="VoxIP Kiosk">
<rect x="2" y="4" width="28" height="19" rx="3" fill="#1d6fd8"/>
<rect x="5" y="7" width="22" height="13" rx="1.5" fill="#0b2545"/>
<path d="M10 10.5l3 6 3-6M18.5 10.5v6M21.5 16.5v-6h2a1.8 1.8 0 010 3.6h-2" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="13" y="23" width="6" height="3" fill="#1d6fd8"/>
<rect x="9" y="26" width="14" height="2.5" rx="1.25" fill="#1d6fd8"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1,17 @@
<fieldset class="radio-row">
<legend>IPv4-adres</legend>
<label class="check"><input type="radio" name="method" value="dhcp" {% if p.method != 'static' %}checked{% endif %}> Automatisch (DHCP)</label>
<label class="check"><input type="radio" name="method" value="static" {% if p.method == 'static' %}checked{% endif %}> Statisch</label>
</fieldset>
<div class="static-fields" data-static-fields>
<label>IP-adres met prefix
<input type="text" name="address" value="{{ p.address }}" placeholder="192.168.1.50/24" autocomplete="off" spellcheck="false">
</label>
<label>Gateway
<input type="text" name="gateway" value="{{ p.gateway }}" placeholder="192.168.1.1" autocomplete="off" spellcheck="false">
</label>
</div>
<label>DNS-servers
<input type="text" name="dns" value="{{ p.dns }}" placeholder="Leeg = via DHCP, bijv. 1.1.1.1, 9.9.9.9" autocomplete="off" spellcheck="false">
<small>Maximaal drie, gescheiden door komma's. Verplicht bij een statisch adres.</small>
</label>
@@ -0,0 +1,19 @@
{% macro badge(status, label, field=None) -%}
<span class="badge badge-{{ status }}" {% if field %}data-badge="{{ field }}"{% endif %}><span class="dot" aria-hidden="true"></span><span data-badge-label>{{ label }}</span></span>
{%- endmacro %}
{% macro action(url, label, cls="btn-primary", confirm=None, confirm_title=None, big=False) -%}
<form method="post" action="{{ url }}" class="inline-form" {% if confirm %}data-confirm="{{ confirm }}" data-confirm-title="{{ confirm_title or label }}"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn {{ cls }}{% if big %} btn-big{% endif %}">{{ label }}</button>
</form>
{%- endmacro %}
{% macro csrf() -%}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{%- endmacro %}
{# Width is applied by app.js: the CSP does not allow inline style attributes. #}
{% macro meter(percent, field) -%}
<div class="meter" role="presentation"><span data-meter="{{ field }}" data-pct="{{ percent }}"></span></div>
{%- endmacro %}
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}{{ title or "Beheer" }}{% endblock %} · VoxIP Kiosk Manager</title>
<link rel="icon" href="{{ url_for('static', filename='logo.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}?v={{ version }}">
<script src="{{ url_for('static', filename='app.js') }}?v={{ version }}" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{{ url_for('ui.dashboard') }}">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="" width="28" height="28">
<span><strong>VoxIP</strong> Kiosk Manager</span>
</a>
{% if logged_in %}
<button class="nav-toggle" type="button" aria-controls="mainnav" aria-expanded="false" data-nav-toggle>
<span class="sr-only">Menu</span><span class="bars" aria-hidden="true"></span>
</button>
<nav id="mainnav" class="mainnav">
<a href="{{ url_for('ui.dashboard') }}" {% if active == 'dashboard' %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{{ url_for('ui.kiosk') }}" {% if active == 'kiosk' %}aria-current="page"{% endif %}>Kiosk</a>
<a href="{{ url_for('ui.system') }}" {% if active == 'system' %}aria-current="page"{% endif %}>Systeem</a>
<a href="{{ url_for('ui.network') }}" {% if active == 'network' %}aria-current="page"{% endif %}>Netwerk</a>
<a href="{{ url_for('ui.logs') }}" {% if active == 'logs' %}aria-current="page"{% endif %}>Logboek</a>
<a href="{{ url_for('ui.change_password') }}" {% if active == 'account' %}aria-current="page"{% endif %}>Wachtwoord</a>
<form method="post" action="{{ url_for('ui.logout') }}" class="nav-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Uitloggen</button>
</form>
</nav>
{% endif %}
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}" role="status">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>{{ device_name }} · {{ hostname }}</span>
<span>VoxIP Kiosk OS {{ version }}</span>
</footer>
<dialog id="confirm-dialog" class="dialog">
<form method="dialog">
<h2 data-confirm-title>Weet je het zeker?</h2>
<p data-confirm-text></p>
<div class="dialog-actions">
<button value="cancel" class="btn btn-secondary">Annuleren</button>
<button value="ok" class="btn btn-danger" data-confirm-ok>Doorgaan</button>
</div>
</form>
</dialog>
</body>
</html>
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, action, meter with context %}
{% set title = "Dashboard" %}
{% block content %}
<div class="page-head">
<h1>Dashboard</h1>
<p class="muted">Ververst automatisch elke 5 seconden.</p>
</div>
<section class="actions-row" aria-label="Snelle acties">
{{ action(url_for('ui.kiosk_reload'), "Pagina herladen", "btn-primary", big=True) }}
{{ action(url_for('ui.kiosk_restart'), "Chromium herstarten", "btn-warning", confirm="Chromium wordt afgesloten en opnieuw gestart. Het scherm is enkele seconden zwart.", big=True) }}
{{ action(url_for('ui.system_reboot'), "Raspberry Pi herstarten", "btn-danger", confirm="De Raspberry Pi herstart. Het scherm en de beheerinterface zijn ongeveer een minuut niet beschikbaar.", big=True) }}
</section>
<div class="grid" data-live-status="{{ url_for('ui.api_status') }}">
<section class="card">
<h2>Kiosk</h2>
<dl class="kv">
<dt>Chromium</dt><dd>{{ badge(s.browser.status, s.browser.label, "browser") }}</dd>
<dt>Kiosk-URL</dt><dd class="break" data-field="url">{{ s.url }}</dd>
<dt>Verbinding</dt><dd>{{ badge(s.network.status, s.network.label, "network") }}</dd>
<dt>Herstarts</dt><dd data-field="browser.restarts">{{ s.browser.restarts }}</dd>
</dl>
{% if s.last_error %}
<p class="note">Laatste melding: <span data-field="last_error.message">{{ s.last_error.message }}</span></p>
{% endif %}
</section>
<section class="card">
<h2>Systeem</h2>
<dl class="kv">
<dt>Uptime</dt><dd data-field="uptime">{{ s.uptime }}</dd>
<dt>CPU-belasting</dt><dd><span data-field="cpu">{{ s.cpu }}</span> <span class="muted">(load <span data-field="load">{{ s.load }}</span>)</span></dd>
<dt>Temperatuur</dt><dd>{{ badge(s.temperature_status, s.temperature, "temperature") }}</dd>
{% if s.power %}<dt>Voeding</dt><dd>{{ badge(s.power.status, s.power.label, "power") }}</dd>{% endif %}
</dl>
</section>
<section class="card">
<h2>Geheugen &amp; opslag</h2>
<dl class="kv">
<dt>RAM</dt><dd><span data-field="memory">{{ s.memory }}</span>{{ meter(s.memory_percent, "memory_percent") }}</dd>
<dt>SD-kaart</dt><dd><span data-field="disk">{{ s.disk }}</span>{{ meter(s.disk_percent, "disk_percent") }}</dd>
</dl>
</section>
<section class="card">
<h2>Netwerk</h2>
<dl class="kv">
<dt>Ethernet</dt><dd data-field="eth0">{{ s.eth0 }}</dd>
<dt>Wifi</dt><dd data-field="wlan0">{{ s.wlan0 }}</dd>
<dt>Hostname</dt><dd>{{ hostname }}.local</dd>
</dl>
</section>
<section class="card">
<h2>Over dit apparaat</h2>
<dl class="kv">
<dt>Naam</dt><dd>{{ device_name }}</dd>
<dt>Versie</dt><dd>VoxIP Kiosk OS {{ s.version }}</dd>
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
<dt>Hardware</dt><dd>{{ s.model }}</dd>
</dl>
</section>
</div>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="card card-narrow">
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</section>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, action, csrf with context %}
{% set title = "Kiosk" %}
{% block content %}
<div class="page-head">
<h1>Kioskbeheer</h1>
{{ badge(s.browser.status, s.browser.label) }}
</div>
<section class="actions-row">
{{ action(url_for('ui.kiosk_reload'), "Pagina herladen", "btn-primary", big=True) }}
{{ action(url_for('ui.kiosk_restart'), "Chromium herstarten", "btn-warning", confirm="Chromium wordt afgesloten en opnieuw gestart.", big=True) }}
</section>
<div class="grid grid-2">
<section class="card">
<h2>Instellingen</h2>
<form method="post" action="{{ url_for('ui.kiosk_settings') }}" class="form">
{{ csrf() }}
<label>Kiosk-URL
<input type="url" name="url" value="{{ conf.url }}" required pattern="https://.+" maxlength="2048" autocomplete="off" spellcheck="false">
<small>Alleen HTTPS. Een toegangstoken in de URL (zoals <code>?token=</code> van InfoFlow) wordt elders afgeschermd getoond.</small>
</label>
<label>Apparaatnaam
<input type="text" name="device_name" value="{{ conf.device_name }}" required maxlength="64">
</label>
<label>Automatisch verversen
<select name="refresh_interval">
{% set intervals = [(0, "Uit"), (60, "Elke minuut"), (300, "Elke 5 minuten"), (900, "Elk kwartier"), (1800, "Elk half uur"), (3600, "Elk uur"), (21600, "Elke 6 uur"), (86400, "Elke dag")] %}
{% set known = intervals|map(attribute=0)|list %}
{% for value, label in intervals %}<option value="{{ value }}" {% if conf.refresh_interval == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
{% if conf.refresh_interval not in known %}<option value="{{ conf.refresh_interval }}" selected>Elke {{ conf.refresh_interval }} seconden</option>{% endif %}
</select>
<small>Wordt overgeslagen zolang de URL niet bereikbaar is, zodat een offline werkende pagina blijft staan.</small>
</label>
<label>Browserprofiel
<select name="browser_profile">
<option value="persistent" {% if conf.browser_profile == 'persistent' %}selected{% endif %}>Bewaren (cookies en offline-cache blijven na herstart)</option>
<option value="ephemeral" {% if conf.browser_profile == 'ephemeral' %}selected{% endif %}>Alleen in werkgeheugen (schoon bij iedere start)</option>
</select>
</label>
<label class="check"><input type="checkbox" name="browser_restart" value="1" {% if conf.browser_restart %}checked{% endif %}> Chromium automatisch herstarten na een crash</label>
<button type="submit" class="btn btn-primary">Opslaan</button>
</form>
</section>
<section class="card">
<h2>Huidig scherm</h2>
<div class="screenshot" data-screenshot="{{ url_for('ui.kiosk_screenshot') }}">
<p class="muted" data-screenshot-msg>Klik op ‘Screenshot maken’ om te zien wat de kiosk nu toont.</p>
<img alt="Screenshot van het kioskscherm" hidden>
</div>
<button type="button" class="btn btn-secondary" data-screenshot-btn>Screenshot maken</button>
<dl class="kv kv-tight">
<dt>Ingestelde URL</dt><dd class="break">{{ masked_url }}</dd>
<dt>Getoonde pagina</dt><dd class="break">{{ agent.page_url or "–" }}</dd>
<dt>Laatste controle</dt><dd>{% if agent.checked_at %}<time data-epoch="{{ agent.checked_at }}"></time> — {% if agent.target_reachable %}bereikbaar{% else %}{{ agent.reason or "niet bereikbaar" }}{% endif %}{% else %}–{% endif %}</dd>
{% if agent.offline_since %}<dt>Offline sinds</dt><dd><time data-epoch="{{ agent.offline_since }}"></time></dd>{% endif %}
</dl>
</section>
</div>
<section class="card">
<h2>Laatste meldingen</h2>
{% if errors %}
<pre class="log log-short">{% for line in errors %}{{ line }}
{% endfor %}</pre>
{% else %}
<p class="muted">Geen waarschuwingen of fouten.</p>
{% endif %}
<p><a href="{{ url_for('ui.logs', source='kiosk') }}">Alle kiosklogs bekijken →</a></p>
</section>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% set title = "Inloggen" %}
{% block content %}
<section class="card card-narrow">
<h1>Inloggen</h1>
<p class="muted">{{ device_name }} · {{ hostname }}</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Beheerderswachtwoord
<input type="password" name="password" required autofocus autocomplete="current-password" maxlength="128">
</label>
<button type="submit" class="btn btn-primary btn-block">Inloggen</button>
</form>
<p class="muted small">Wachtwoord vergeten? Log via SSH in en voer <code>sudo voxip-kiosk-ctl reset-password</code> uit.</p>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% set title = "Logboek" %}
{% block content %}
<div class="page-head"><h1>Logboek</h1></div>
<section class="card">
<form method="get" class="form form-inline">
<label>Bron
<select name="source">
{% for key, item in sources.items() %}<option value="{{ key }}" {% if key == source %}selected{% endif %}>{{ item[0] }}</option>{% endfor %}
</select>
</label>
<label>Regels
<select name="lines">
{% for n in line_options %}<option value="{{ n }}" {% if n == lines %}selected{% endif %}>{{ n }}</option>{% endfor %}
</select>
</label>
<label>Niveau
<select name="priority">
<option value="" {% if not priority %}selected{% endif %}>Alles</option>
<option value="warning" {% if priority == 'warning' %}selected{% endif %}>Waarschuwingen en fouten</option>
<option value="err" {% if priority == 'err' %}selected{% endif %}>Alleen fouten</option>
</select>
</label>
<button type="submit" class="btn btn-primary">Tonen</button>
</form>
<pre class="log" data-scroll-bottom>{{ text }}</pre>
</section>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% set title = "Beheerinterface herstart" %}
{% block content %}
<section class="card card-narrow" data-redirect="{{ target }}" data-redirect-after="8">
<h1>Beheerinterface herstart</h1>
<p>De beheerinterface is straks bereikbaar op:</p>
<p><a href="{{ target }}"><code>{{ target }}</code></a></p>
<p class="muted">Je wordt over enkele seconden automatisch doorgestuurd. Bij HTTPS met een zelfondertekend
certificaat vraagt je browser eenmalig om bevestiging.</p>
</section>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, csrf with context %}
{% set title = "Netwerk" %}
{% block content %}
<div class="page-head"><h1>Netwerkbeheer</h1></div>
{% if pending %}
<section class="card card-pending" data-pending="{{ url_for('ui.api_network_pending') }}" data-remaining="{{ pending.remaining }}">
<h2>Netwerkwijziging bevestigen</h2>
<p>De nieuwe instellingen voor <strong>{{ 'ethernet' if pending.interface == 'eth0' else 'wifi' }}</strong>
{% if pending.method == 'static' %}(statisch adres <strong>{{ pending.address }}</strong>){% else %}(DHCP){% endif %} zijn actief.
Werkt de beheerinterface nog? Bevestig dan. Zonder bevestiging worden over
<strong data-countdown>{{ pending.remaining }}</strong> seconden automatisch de vorige instellingen teruggezet.</p>
{% if pending.method == 'static' %}
<p class="muted">Is het adres veranderd? Open dan <code>{{ admin_scheme }}://{{ pending.address.split('/')[0] }}:{{ admin_port }}/network</code>, log in en bevestig daar.</p>
{% endif %}
<div class="actions-row">
<form method="post" action="{{ url_for('ui.network_confirm') }}" class="inline-form">{{ csrf() }}<button class="btn btn-success btn-big">Bevestigen</button></form>
<form method="post" action="{{ url_for('ui.network_rollback') }}" class="inline-form">{{ csrf() }}<button class="btn btn-secondary btn-big">Nu terugdraaien</button></form>
</div>
</section>
{% endif %}
{% if not net.available %}
<div class="alert alert-error">NetworkManager (nmcli) is niet beschikbaar op dit systeem.</div>
{% endif %}
<div class="grid grid-2">
{% for iface in ['eth0', 'wlan0'] %}
{% set d = net.devices[iface] %}
<section class="card">
<h2>{{ 'Ethernet' if iface == 'eth0' else 'Wifi' }} <span class="muted small">({{ iface }})</span></h2>
<dl class="kv">
<dt>Status</dt><dd>{{ badge('ok' if d.connected else ('starting' if d.state == 'connecting' else 'stopped'), d.state_label) }}</dd>
{% if d.connection %}<dt>Verbinding</dt><dd>{{ d.connection }}</dd>{% endif %}
<dt>IP-adres</dt><dd>{{ d.addresses|join(', ') or '–' }}</dd>
<dt>Gateway</dt><dd>{{ d.gateway or '–' }}</dd>
<dt>DNS</dt><dd>{{ d.dns|join(', ') or '–' }}</dd>
{% if d.hwaddr %}<dt>MAC</dt><dd><code>{{ d.hwaddr }}</code></dd>{% endif %}
</dl>
</section>
{% endfor %}
</div>
<div class="grid grid-2">
<section class="card">
<h2>Ethernet instellen</h2>
{% set p = net.devices.eth0.profile %}
<form method="post" action="{{ url_for('ui.network_apply') }}" class="form" data-ipform
data-confirm="De netwerkinstellingen worden gewijzigd. Bevestig daarna binnen {{ rollback_seconds // 60 }} minuten, anders worden ze teruggezet." data-confirm-title="Ethernet wijzigen">
{{ csrf() }}
<input type="hidden" name="interface" value="eth0">
{% include "_ipv4_fields.html" %}
<button type="submit" class="btn btn-primary">Toepassen</button>
</form>
</section>
<section class="card">
<h2>Wifi instellen</h2>
{% set p = net.devices.wlan0.profile %}
<form method="post" action="{{ url_for('ui.network_apply') }}" class="form" data-ipform
data-confirm="De wifi-instellingen worden gewijzigd. Bevestig daarna binnen {{ rollback_seconds // 60 }} minuten, anders worden ze teruggezet." data-confirm-title="Wifi wijzigen">
{{ csrf() }}
<input type="hidden" name="interface" value="wlan0">
<label>Netwerknaam (SSID)
<span class="input-row">
<input type="text" name="ssid" value="{{ p.ssid }}" required maxlength="32" list="wifi-networks" autocomplete="off" spellcheck="false">
<button type="button" class="btn btn-secondary" data-wifi-scan="{{ url_for('ui.network_scan') }}">Scannen</button>
</span>
<datalist id="wifi-networks"></datalist>
</label>
<ul class="scan-results" data-scan-results hidden></ul>
<label>Wachtwoord
<input type="password" name="psk" minlength="8" maxlength="64" autocomplete="new-password" {% if p.exists %}placeholder="Leeg laten om het huidige wachtwoord te behouden"{% endif %}>
</label>
<label class="check"><input type="checkbox" name="open" value="1"> Open netwerk (zonder wachtwoord)</label>
<label class="check"><input type="checkbox" name="hidden" value="1" {% if p.hidden %}checked{% endif %}> Verborgen netwerk</label>
{% include "_ipv4_fields.html" %}
<button type="submit" class="btn btn-primary">Toepassen</button>
</form>
<h2 class="mt">Wifi-land</h2>
<form method="post" action="{{ url_for('ui.network_country') }}" class="form form-inline">
{{ csrf() }}
<label>Landcode
<input type="text" name="country" value="{{ net.wifi_country or 'NL' }}" required maxlength="2" pattern="[A-Za-z]{2}" class="input-short">
</label>
<button type="submit" class="btn btn-secondary">Instellen</button>
</form>
</section>
</div>
<section class="card">
<h2>Herstel bij een onbereikbare kiosk</h2>
<ul class="plain">
<li>Iedere wijziging wordt na {{ rollback_seconds // 60 }} minuten automatisch teruggedraaid als je hem niet bevestigt, ook als de stroom tussendoor uitvalt.</li>
<li>Noodherstel: zet een leeg bestand <code>voxip-network-reset</code> op de bootpartitie van de SD-kaart en start de Pi. Alle VoxIP-netwerkprofielen worden dan verwijderd (terug naar DHCP).</li>
</ul>
</section>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% set title = "Wachtwoord wijzigen" %}
{% block content %}
<section class="card card-narrow">
<h1>Wachtwoord wijzigen</h1>
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Huidig wachtwoord
<input type="password" name="current_password" required autocomplete="current-password" maxlength="128">
</label>
<label>Nieuw wachtwoord
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<label>Herhaal nieuw wachtwoord
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Wijzigen</button>
</form>
<p class="muted small">Na het wijzigen worden alle andere sessies uitgelogd.</p>
</section>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% set title = "Eerste configuratie" %}
{% block content %}
<section class="card card-narrow">
<h1>Welkom bij VoxIP Kiosk Manager</h1>
<p>Stel eerst een uniek beheerderswachtwoord in. Je hebt daarvoor de <strong>setupcode</strong> nodig die op het
scherm van de kiosk staat (of via SSH: <code>sudo voxip-kiosk-ctl setup-code</code>).</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Setupcode
<input type="text" name="setup_code" required autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="ABCD-EFGH" maxlength="12" class="input-code">
</label>
<label>Nieuw wachtwoord
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
<small>Minimaal 10 tekens. Gebruik voor iedere kiosk een eigen wachtwoord.</small>
</label>
<label>Herhaal wachtwoord
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Wachtwoord instellen</button>
</form>
</section>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% from "_macros.html" import action, csrf with context %}
{% set title = "Systeem" %}
{% block content %}
<div class="page-head"><h1>Systeembeheer</h1></div>
<section class="actions-row">
{{ action(url_for('ui.kiosk_restart'), "Chromium herstarten", "btn-warning", confirm="Chromium wordt afgesloten en opnieuw gestart.", big=True) }}
{{ action(url_for('ui.system_reboot'), "Raspberry Pi herstarten", "btn-danger", confirm="De Raspberry Pi herstart. Het scherm en de beheerinterface zijn ongeveer een minuut niet beschikbaar.", big=True) }}
{{ action(url_for('ui.system_poweroff'), "Veilig uitschakelen", "btn-dark", confirm="De Raspberry Pi wordt uitgeschakeld. Daarna kan hij alleen weer aan door de stroom los en weer vast te maken.", confirm_title="Raspberry Pi uitschakelen", big=True) }}
</section>
<div class="grid grid-2">
<section class="card">
<h2>Scherm</h2>
<form method="post" action="{{ url_for('ui.system_display') }}" class="form">
{{ csrf() }}
<label>Rotatie
<select name="rotation">
{% for deg, label in [(0, "0° (normaal)"), (90, "90° (rechtsom)"), (180, "180° (ondersteboven)"), (270, "270° (linksom)")] %}
<option value="{{ deg }}" {% if conf.rotation == deg %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Resolutie
<select name="resolution">
<option value="auto" {% if conf.resolution == 'auto' %}selected{% endif %}>Automatisch (voorkeur van het scherm)</option>
{% for mode in modes %}<option value="{{ mode }}" {% if conf.resolution == mode %}selected{% endif %}>{{ mode }}</option>{% endfor %}
{% if conf.resolution != 'auto' and conf.resolution not in modes %}<option value="{{ conf.resolution }}" selected>{{ conf.resolution }}</option>{% endif %}
</select>
<small>{% if output %}Aangesloten: {{ output.name }}{% if output.current %}, nu {{ output.current }}{% endif %}.{% else %}Schermgegevens verschijnen zodra de kiosk draait.{% endif %}</small>
</label>
<label>Zoomfactor
<input type="number" name="zoom" min="0.25" max="4" step="0.05" value="{{ conf.zoom }}">
<small>1 = normaal. Groter maakt alles groter (handig op grote tv's).</small>
</label>
<label class="check"><input type="checkbox" name="screen_always_on" value="1" {% if conf.screen_always_on %}checked{% endif %} data-toggle-target="#screen-off"> Scherm altijd aan (geen screensaver of energiebeheer)</label>
<label id="screen-off">Scherm uitschakelen na (minuten zonder invoer)
<input type="number" name="screen_off_minutes" min="1" max="240" value="{{ conf.screen_off_minutes }}">
</label>
<label class="check"><input type="checkbox" name="apply_now" value="1" checked> Direct toepassen (Chromium herstart)</label>
<button type="submit" class="btn btn-primary">Opslaan</button>
</form>
</section>
<section class="card">
<h2>Hostname</h2>
<form method="post" action="{{ url_for('ui.system_hostname') }}" class="form">
{{ csrf() }}
<label>Hostname
<input type="text" name="hostname" value="{{ hostname }}" required maxlength="63" pattern="[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?" autocomplete="off" spellcheck="false">
<small>De beheerinterface is daarna bereikbaar via <code>http://&lt;hostname&gt;.local:{{ conf.admin_port }}</code>. Geef iedere kiosk op hetzelfde netwerk een unieke naam.</small>
</label>
<button type="submit" class="btn btn-primary">Hostname wijzigen</button>
</form>
<h2 class="mt">Beheerinterface</h2>
<form method="post" action="{{ url_for('ui.system_admin') }}" class="form" data-confirm="Als je poort of HTTPS wijzigt, herstart de beheerinterface en word je doorgestuurd naar het nieuwe adres." data-confirm-title="Beheerinterface aanpassen">
{{ csrf() }}
<label>Poort
<input type="number" name="admin_port" min="1024" max="65535" value="{{ conf.admin_port }}">
</label>
<label class="check"><input type="checkbox" name="admin_https" value="1" {% if conf.admin_https %}checked{% endif %}> HTTPS gebruiken (zelfondertekend certificaat)</label>
<label class="check"><input type="checkbox" name="admin_allow_public" value="1" {% if conf.admin_allow_public %}checked{% endif %}> Toegang vanaf publieke IP-adressen toestaan <span class="muted">(afgeraden; gebruik liever VPN)</span></label>
<button type="submit" class="btn btn-secondary">Opslaan</button>
</form>
</section>
</div>
<section class="card">
<h2>Systeeminformatie</h2>
<dl class="kv">
<dt>Versie</dt><dd>VoxIP Kiosk OS {{ s.version }}</dd>
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
<dt>Hardware</dt><dd>{{ s.model }}</dd>
<dt>Uptime</dt><dd>{{ s.uptime }}</dd>
</dl>
<p><a href="{{ url_for('ui.logs', source='system') }}">Systeemlogs bekijken →</a></p>
</section>
{% endblock %}
+551
View File
@@ -0,0 +1,551 @@
"""Routes of the VoxIP Kiosk Manager."""
from __future__ import annotations
import json
import logging
import socket
import time
from typing import Any
from flask import (Blueprint, Response, abort, current_app, flash, g, jsonify, redirect, render_template, request,
session, url_for)
from .. import netconfig, netinfo, services, sysinfo
from ..agent import read_state
from ..cdp import CDPError
from ..config import ConfigError, load_config, save_config
from ..helper_client import HelperError
from ..validation import ValidationError, mask_url
from . import security
from .auth import password_problem
bp = Blueprint("ui", __name__)
audit_log = logging.getLogger("voxip-kiosk-audit")
IDLE_TIMEOUT = 3600
PUBLIC_ENDPOINTS = {"ui.login", "ui.setup", "static"}
def svc():
return current_app.extensions["voxip"]
def cfg() -> dict[str, Any]:
if "cfg" not in g:
g.cfg = load_config(svc().paths.config_file)
return g.cfg
def client_ip() -> str:
return request.remote_addr or "?"
def audit(event: str, **details: Any) -> None:
record = {"time": int(time.time()), "event": event, "ip": client_ip(), **details}
audit_log.info(json.dumps(record, ensure_ascii=False))
try:
with open(svc().paths.audit_log, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError:
pass
def wants_json() -> bool:
return request.path.startswith("/api/") or request.headers.get("Accept", "").startswith("application/json")
_local_nets_cache: tuple[float, list[str]] = (0.0, [])
def local_networks() -> list[str]:
global _local_nets_cache
now = time.monotonic()
if now - _local_nets_cache[0] > 60:
nets = [a for addrs in sysinfo.ip_addresses().values() for a in addrs]
_local_nets_cache = (now, nets)
return _local_nets_cache[1]
# -- request pipeline ----------------------------------------------------------
@bp.before_app_request
def guard():
if not security.client_allowed(request.remote_addr, cfg()["admin_allow_public"], local_networks()):
abort(403)
if request.endpoint == "static":
return None
creds = svc().credentials
if not creds.is_configured():
if request.endpoint != "ui.setup":
return redirect(url_for("ui.setup"))
elif request.endpoint == "ui.setup":
return redirect(url_for("ui.dashboard"))
if session.get("auth"):
expired = time.time() - session.get("seen", 0) > IDLE_TIMEOUT
if expired or session.get("epoch") != creds.session_epoch():
session.clear()
flash("Je sessie is verlopen. Log opnieuw in.", "info")
if request.endpoint not in PUBLIC_ENDPOINTS and not session.get("auth"):
if wants_json():
return jsonify(error="Niet ingelogd"), 401
return redirect(url_for("ui.login"))
if session.get("auth"):
session["seen"] = int(time.time())
if request.method == "POST" and not security.csrf_valid(request):
audit("csrf_rejected", path=request.path)
if wants_json():
return jsonify(error="Beveiligingstoken ongeldig. Herlaad de pagina."), 400
flash("Beveiligingstoken ongeldig of verlopen. Probeer het opnieuw.", "error")
return redirect(request.path if request.endpoint in PUBLIC_ENDPOINTS else url_for("ui.dashboard")), 303
return None
@bp.after_app_request
def headers(response: Response):
return security.apply_headers(response, secure=current_app.config["SESSION_COOKIE_SECURE"])
@bp.app_context_processor
def template_globals():
return {
"csrf_token": security.csrf_token,
"version": svc().version,
"device_name": cfg()["device_name"],
"hostname": socket.gethostname().split(".")[0],
"logged_in": bool(session.get("auth")),
"admin_port": cfg()["admin_port"],
"admin_scheme": "https" if cfg()["admin_https"] else "http",
}
@bp.app_errorhandler(403)
def forbidden(_):
return render_template("error.html", title="Geen toegang",
message="De beheerinterface is alleen bereikbaar vanaf het lokale netwerk of via VPN."), 403
@bp.app_errorhandler(404)
def not_found(_):
return render_template("error.html", title="Niet gevonden", message="Deze pagina bestaat niet."), 404
@bp.app_errorhandler(413)
def too_large(_):
return render_template("error.html", title="Te groot", message="Het verzoek is te groot."), 413
# -- setup & login --------------------------------------------------------------
@bp.route("/setup", methods=["GET", "POST"])
def setup():
creds = svc().credentials
error = None
if request.method == "POST":
ip = client_ip()
wait = svc().throttle.retry_after(ip)
password = request.form.get("password", "")
if wait:
error = f"Te veel pogingen. Probeer het over {wait} seconden opnieuw."
elif not creds.check_setup_code(request.form.get("setup_code", "")):
svc().throttle.failure(ip)
audit("setup_code_rejected")
error = "De setupcode klopt niet. Je vindt de code op het scherm van de kiosk."
else:
error = password_problem(password, request.form.get("password_confirm", ""))
if error is None:
epoch = creds.set_password(password)
svc().throttle.success(ip)
session.clear()
session.update(auth=True, epoch=epoch, seen=int(time.time()))
session.permanent = True
audit("setup_completed")
flash("Het beheerderswachtwoord is ingesteld. Welkom!", "success")
return redirect(url_for("ui.dashboard"))
return render_template("setup.html", error=error)
@bp.route("/login", methods=["GET", "POST"])
def login():
if session.get("auth"):
return redirect(url_for("ui.dashboard"))
error = None
if request.method == "POST":
ip = client_ip()
wait = svc().throttle.retry_after(ip)
if wait:
error = f"Te veel mislukte pogingen. Probeer het over {wait} seconden opnieuw."
elif svc().credentials.verify(request.form.get("password", "")):
svc().throttle.success(ip)
session.clear()
session.update(auth=True, epoch=svc().credentials.session_epoch(), seen=int(time.time()))
session.permanent = True
audit("login")
return redirect(url_for("ui.dashboard"))
else:
svc().throttle.failure(ip)
audit("login_failed")
error = "Onjuist wachtwoord."
return render_template("login.html", error=error)
@bp.post("/logout")
def logout():
audit("logout")
session.clear()
flash("Je bent uitgelogd.", "info")
return redirect(url_for("ui.login"))
@bp.route("/account/password", methods=["GET", "POST"])
def change_password():
if request.method == "POST":
creds = svc().credentials
if not creds.verify(request.form.get("current_password", "")):
audit("password_change_failed")
flash("Het huidige wachtwoord klopt niet.", "error")
else:
password = request.form.get("password", "")
problem = password_problem(password, request.form.get("password_confirm", ""))
if problem:
flash(problem, "error")
else:
epoch = creds.set_password(password)
session["epoch"] = epoch
audit("password_changed")
flash("Wachtwoord gewijzigd. Andere sessies zijn uitgelogd.", "success")
return redirect(url_for("ui.dashboard"))
return render_template("password.html", active="account")
# -- dashboard -------------------------------------------------------------------
def browser_state(units: dict[str, Any], agent: dict[str, Any]) -> tuple[str, str]:
unit = units.get("browser", {})
if unit.get("status") != "ok":
return unit.get("status", "unknown"), unit.get("label", "Onbekend")
if agent.get("browser") == "unreachable":
return "starting", "Bezig met starten"
page = agent.get("page")
if page == "offline":
return "warning", "Actief — offlinepagina"
if page == "setup":
return "warning", "Actief — wacht op initiële configuratie"
if page in ("start", "blank", "error"):
return "starting", "Pagina wordt geladen"
return "ok", "Actief"
def collect_status() -> dict[str, Any]:
conf = cfg()
paths = svc().paths
agent = read_state(paths.agent_state_file)
units = services.summary()
b_status, b_label = browser_state(units, agent)
mem = sysinfo.memory()
disk = sysinfo.disk("/")
temp = sysinfo.temperature_c()
load = sysinfo.load_average()
cpu = sysinfo.cpu_percent()
throttle = sysinfo.throttled()
reachable = agent.get("target_reachable")
if reachable is None:
net_status, net_label = "unknown", "Nog niet gecontroleerd"
elif reachable:
net_status, net_label = "ok", "Online — kiosk-URL bereikbaar"
else:
net_status, net_label = "error", f"Offline — {agent.get('reason') or 'niet bereikbaar'}"
addresses = sysinfo.ip_addresses()
return {
"browser": {"status": b_status, "label": b_label, "restarts": units.get("browser", {}).get("restarts", "0")},
"agent": units.get("agent", {}),
"url": mask_url(conf["url"]),
"refresh_interval": conf["refresh_interval"],
"uptime": sysinfo.format_duration(sysinfo.uptime_seconds()),
"cpu": f"{cpu:.0f}%".replace(".", ",") if cpu is not None else "–",
"cpu_percent": cpu or 0,
"load": " / ".join(f"{x:.2f}".replace(".", ",") for x in load) if load else "–",
"temperature": f"{temp:.1f} °C".replace(".", ",") if temp is not None else "–",
"temperature_status": "error" if temp and temp >= 80 else "warning" if temp and temp >= 70 else "ok",
"memory": f"{sysinfo.format_bytes(mem['used'])} van {sysinfo.format_bytes(mem['total'])}" if mem else "–",
"memory_percent": mem["percent"] if mem else 0,
"disk": f"{sysinfo.format_bytes(disk['used'])} van {sysinfo.format_bytes(disk['total'])}" if disk else "–",
"disk_percent": disk["percent"] if disk else 0,
"eth0": ", ".join(addresses.get("eth0", [])) or "Niet verbonden",
"wlan0": ", ".join(addresses.get("wlan0", [])) or "Niet verbonden",
"network": {"status": net_status, "label": net_label},
"power": {"status": "error" if throttle and throttle["now"] else "warning" if throttle and throttle["ever"] else "ok",
"label": ", ".join(throttle["flags"]) if throttle and throttle["flags"] else "In orde"} if throttle else None,
"last_error": agent.get("last_error"),
"version": svc().version,
"os": sysinfo.os_release(),
"model": sysinfo.model(),
}
@bp.get("/")
def dashboard():
return render_template("dashboard.html", active="dashboard", s=collect_status())
@bp.get("/api/status")
def api_status():
return jsonify(collect_status())
# -- kiosk -----------------------------------------------------------------------
@bp.get("/kiosk")
def kiosk():
agent = read_state(svc().paths.agent_state_file)
return render_template("kiosk.html", active="kiosk", conf=cfg(), agent=agent,
errors=services.recent_errors(25), masked_url=mask_url(cfg()["url"]),
s={"browser": dict(zip(("status", "label"), browser_state(services.summary(), agent)))})
def _form_bool(name: str) -> bool:
return request.form.get(name) in ("1", "on", "true", "yes")
def _save(updates: dict[str, Any]) -> dict[str, Any] | None:
try:
return save_config(svc().paths.config_file, updates)
except ConfigError as exc:
for message in exc.errors.values():
flash(message, "error")
except OSError as exc:
flash(f"Opslaan mislukt: {exc}", "error")
return None
@bp.post("/kiosk/settings")
def kiosk_settings():
updates = {
"url": request.form.get("url", ""),
"device_name": request.form.get("device_name", ""),
"refresh_interval": request.form.get("refresh_interval", "0"),
"browser_restart": _form_bool("browser_restart"),
"browser_profile": request.form.get("browser_profile", "persistent"),
}
old = cfg()
new = _save(updates)
if new is not None:
changed = sorted(k for k in updates if old.get(k) != new.get(k))
audit("kiosk_settings", changed=changed, url=mask_url(new["url"]))
message = "Instellingen opgeslagen."
if "url" in changed:
message += " De kiosk opent de nieuwe URL binnen enkele seconden."
if "browser_profile" in changed:
message += " Het profieltype geldt na het herstarten van Chromium."
flash(message, "success")
return redirect(url_for("ui.kiosk"))
def _action_response(ok: bool, message: str, back: str):
if wants_json():
return jsonify(ok=ok, message=message), (200 if ok else 500)
flash(message, "success" if ok else "error")
return redirect(back)
@bp.post("/kiosk/reload")
def kiosk_reload():
try:
svc().cdp.reload()
audit("page_reload")
return _action_response(True, "De pagina wordt herladen.", request.referrer or url_for("ui.dashboard"))
except CDPError as exc:
return _action_response(False, f"Herladen mislukt: {exc}", request.referrer or url_for("ui.dashboard"))
def _helper_action(event: str, verb: str, *args: str, back: str):
try:
result = svc().helper.call(verb, *args)
audit(event, **({"args": list(args)} if args else {}))
return _action_response(True, result.get("message", "Uitgevoerd."), back)
except HelperError as exc:
audit(event + "_failed", error=str(exc))
return _action_response(False, str(exc), back)
@bp.post("/kiosk/restart")
def kiosk_restart():
return _helper_action("browser_restart", "restart-browser", back=request.referrer or url_for("ui.dashboard"))
@bp.get("/kiosk/screenshot.jpg")
def kiosk_screenshot():
try:
data = svc().cdp.screenshot(quality=70)
except CDPError as exc:
return Response(f"Screenshot niet beschikbaar: {exc}", status=503, mimetype="text/plain")
return Response(data, mimetype="image/jpeg")
# -- system ----------------------------------------------------------------------
def display_info() -> dict[str, Any]:
try:
return json.loads(svc().paths.display_info_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
@bp.get("/system")
def system():
info = display_info()
outputs = info.get("outputs") or []
modes = outputs[0]["modes"] if outputs else []
return render_template("system.html", active="system", conf=cfg(), modes=modes,
output=outputs[0] if outputs else None, s=collect_status())
@bp.post("/system/display")
def system_display():
updates = {
"rotation": request.form.get("rotation", "0"),
"resolution": request.form.get("resolution", "auto"),
"zoom": request.form.get("zoom", "1").replace(",", "."),
"screen_always_on": _form_bool("screen_always_on"),
"screen_off_minutes": request.form.get("screen_off_minutes", "10"),
}
if _save(updates) is None:
return redirect(url_for("ui.system"))
audit("display_settings", **{k: v for k, v in updates.items()})
if _form_bool("apply_now"):
return _helper_action("browser_restart", "restart-browser", back=url_for("ui.system"))
flash("Scherminstellingen opgeslagen. Ze worden actief bij de volgende herstart van Chromium.", "success")
return redirect(url_for("ui.system"))
@bp.post("/system/hostname")
def system_hostname():
return _helper_action("hostname", "set-hostname", request.form.get("hostname", "").strip().lower(), back=url_for("ui.system"))
@bp.post("/system/reboot")
def system_reboot():
return _helper_action("reboot", "reboot", back=url_for("ui.dashboard"))
@bp.post("/system/poweroff")
def system_poweroff():
return _helper_action("poweroff", "poweroff", back=url_for("ui.dashboard"))
@bp.post("/system/admin")
def system_admin():
updates = {
"admin_port": request.form.get("admin_port", "8080"),
"admin_https": _form_bool("admin_https"),
"admin_allow_public": _form_bool("admin_allow_public"),
}
old = cfg()
new = _save(updates)
if new is None:
return redirect(url_for("ui.system"))
audit("admin_settings", **updates)
if any(old[k] != new[k] for k in ("admin_port", "admin_https")):
scheme = "https" if new["admin_https"] else "http"
target = f"{scheme}://{request.host.rsplit(':', 1)[0]}:{new['admin_port']}/"
try:
svc().helper.call("restart-manager")
except HelperError as exc:
flash(f"Opgeslagen, maar herstarten van de beheerinterface mislukte: {exc}", "error")
return redirect(url_for("ui.system"))
return render_template("moved.html", target=target)
flash("Instellingen opgeslagen.", "success")
return redirect(url_for("ui.system"))
# -- logs ------------------------------------------------------------------------
@bp.get("/logs")
def logs():
source = request.args.get("source", "kiosk")
if source not in services.LOG_SOURCES:
source = "kiosk"
try:
lines = int(request.args.get("lines", 100))
except ValueError:
lines = 100
priority = request.args.get("priority") or None
text = services.journal(source, lines, priority)
return render_template("logs.html", active="logs", text=text, source=source, lines=lines, priority=priority,
sources=services.LOG_SOURCES, line_options=services.LOG_LINES)
# -- network -----------------------------------------------------------------------
def _pending() -> dict[str, Any] | None:
pending = netinfo.pending_change(svc().paths.network_pending_file)
if pending:
pending = {k: pending.get(k) for k in ("interface", "method", "address", "created", "deadline")}
pending["remaining"] = max(0, int(pending.get("deadline") or 0) - int(time.time()))
return pending
@bp.get("/network")
def network():
return render_template("network.html", active="network", net=netinfo.overview(), pending=_pending(),
rollback_seconds=netconfig.ROLLBACK_SECONDS)
@bp.get("/api/network/pending")
def api_network_pending():
return jsonify(pending=_pending())
@bp.post("/network/apply")
def network_apply():
interface = request.form.get("interface", "")
payload: dict[str, Any] = {
"interface": interface,
"method": request.form.get("method", "dhcp"),
"address": request.form.get("address", ""),
"gateway": request.form.get("gateway", ""),
"dns": request.form.get("dns", ""),
}
if interface == "wlan0":
payload["wifi"] = {
"ssid": request.form.get("ssid", ""),
"psk": request.form.get("psk", ""),
"hidden": _form_bool("hidden"),
"open": _form_bool("open"),
}
try:
netconfig.validate_request(payload) # early feedback; the helper validates again
svc().helper.call("network-apply", payload=payload, timeout=60)
except (ValidationError, HelperError) as exc:
flash(str(exc), "error")
return redirect(url_for("ui.network"))
audit("network_apply", interface=interface, method=payload["method"], address=payload["address"])
flash(f"Netwerkwijziging toegepast. Bevestig binnen {netconfig.ROLLBACK_SECONDS // 60} minuten, anders worden de "
"vorige instellingen automatisch teruggezet.", "warning")
return redirect(url_for("ui.network"))
@bp.post("/network/confirm")
def network_confirm():
return _helper_action("network_confirm", "network-confirm", back=url_for("ui.network"))
@bp.post("/network/rollback")
def network_rollback():
return _helper_action("network_rollback", "network-rollback", back=url_for("ui.network"))
@bp.post("/network/country")
def network_country():
return _helper_action("wifi_country", "set-wifi-country", request.form.get("country", "").strip().upper(), back=url_for("ui.network"))
@bp.post("/api/network/scan")
def network_scan():
try:
result = svc().helper.call("wifi-scan", timeout=60)
except HelperError as exc:
return jsonify(error=str(exc)), 500
return jsonify(networks=result.get("networks", []))