Rename the Python package to daynivo_kiosk
CI / test-bookworm (push) Successful in 29s
CI / test-latest (push) Successful in 33s

src/voxip_kiosk -> src/daynivo_kiosk, with every import, the launchers,
install.sh, the Makefile and the manager's gunicorn app path. An upgrade
replaces /usr/lib/voxip-kiosk/python as a whole, so the old package
disappears; tested by upgrading an installation of the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-05 23:52:39 +01:00
1 parent d0f3206ff4
commit 6733aacb6e
62 files changed
+43 -43

No files matched your search

+603
View File
@@ -0,0 +1,603 @@
"""Routes of the Daynivo 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 ..updater import read_state as read_update_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:
current_app.logger.warning("Herladen mislukt: %s", exc)
return _action_response(
False,
"Herladen mislukt: Chromium reageert niet. Als hij net (her)start, probeer het over een halve minuut opnieuw.",
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(), update=update_view())
def _when(timestamp: Any) -> str | None:
if not isinstance(timestamp, (int, float)):
return None
return time.strftime("%d-%m-%Y %H:%M", time.localtime(timestamp))
def update_view() -> dict[str, Any]:
"""The updater's last result (update.json, written by root) in display form."""
state = read_update_state(svc().paths)
return {
**state,
"checked": _when(state.get("checked_at")),
"finished": _when(state.get("finished_at")),
# A run that started more than two hours ago and never finished is reported as failed.
"running": state.get("status") == "updating" and time.time() - state.get("started_at", 0) < 7200,
}
@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"))
@bp.post("/system/updates")
def system_updates():
auto = _form_bool("auto_update")
if _save({"auto_update": auto}) is not None:
audit("update_settings", auto_update=auto)
flash("Automatisch bijwerken staat " + ("aan." if auto else "uit."), "success")
return redirect(url_for("ui.system") + "#updates")
@bp.post("/system/update/check")
def system_update_check():
back = url_for("ui.system") + "#updates"
try:
state = svc().helper.call("update-check", timeout=60).get("update", {})
except HelperError as exc:
return _action_response(False, str(exc), back)
if state.get("check_error"):
return _action_response(False, f"Controleren mislukt: {state['check_error']}", back)
if state.get("available"):
return _action_response(True, f"Versie {state.get('latest')} is beschikbaar.", back)
return _action_response(True, f"Je hebt de nieuwste versie ({state.get('current')}).", back)
@bp.post("/system/update/install")
def system_update_install():
return _helper_action("update_install", "update-install", back=url_for("ui.system") + "#updates")
# -- 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", []))