Rename the Python package to daynivo_kiosk
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:
1 parent
d0f3206ff4
commit
6733aacb6e
62 files changed
+43
-43
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Daynivo Kiosk OS — kiosk runtime and Daynivo Kiosk Manager."""
|
||||
@@ -0,0 +1,295 @@
|
||||
"""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 admin_url, 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
|
||||
NO_NETWORK = "Geen netwerkverbinding (netwerkkabel los?)"
|
||||
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": "Daynivo-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 has_network(self) -> bool:
|
||||
"""False when no interface has an address, e.g. the cable is out and there is no wifi."""
|
||||
return bool(self.addresses())
|
||||
|
||||
def show_offline(self, config: dict[str, Any], reason: str) -> None:
|
||||
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))
|
||||
|
||||
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 "",
|
||||
"url": mask_url(config["url"]), "admin": admin_url(config, self.hostname()),
|
||||
"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 not ok and not self.has_network():
|
||||
reason = NO_NETWORK
|
||||
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:
|
||||
self.show_offline(config, reason)
|
||||
self.hung = 0
|
||||
return
|
||||
|
||||
# Without any network the page cannot recover by itself: say so on screen. When only
|
||||
# the server is unreachable the page stays, so Daynivo can show its offline cache.
|
||||
if not self.has_network():
|
||||
self.state.update(target_reachable=False, reason=NO_NETWORK, checked_at=int(self.wall()))
|
||||
self._probe = None
|
||||
self.hung = 0
|
||||
self.show_offline(config, NO_NETWORK)
|
||||
return
|
||||
|
||||
if self.last_load is None:
|
||||
self.last_load = self.clock()
|
||||
|
||||
if not self.cdp.responsive(page.get("webSocketDebuggerUrl")):
|
||||
self.hung += 1
|
||||
if self.hung == HUNG_RELOAD_AFTER:
|
||||
self.error("Pagina reageert niet; herladen")
|
||||
self.cdp.reload()
|
||||
self.last_load = self.clock()
|
||||
elif self.hung >= HUNG_RESTART_AFTER and config["browser_restart"]:
|
||||
self.error("Pagina reageert nog steeds niet; Chromium wordt herstart")
|
||||
self.hung = 0
|
||||
self.cdp.close_browser()
|
||||
return
|
||||
self.hung = 0
|
||||
|
||||
ok, reason = self.reachable(target, PROBE_MAX_AGE_ONLINE)
|
||||
interval = config["refresh_interval"]
|
||||
if interval and self.clock() - self.last_load >= interval:
|
||||
if ok:
|
||||
log.info("Automatisch verversen (interval %s s)", interval)
|
||||
self.cdp.reload()
|
||||
self.last_load = self.clock()
|
||||
self.refresh_skipped_logged = False
|
||||
elif not self.refresh_skipped_logged:
|
||||
self.error(f"Verversen overgeslagen: kiosk-URL niet bereikbaar ({reason})")
|
||||
self.refresh_skipped_logged = True
|
||||
|
||||
def write_state(self) -> None:
|
||||
state = dict(self.state, last_load_wall=int(self.wall() - (self.clock() - self.last_load)) if self.last_load else None)
|
||||
payload = json.dumps(state, sort_keys=True)
|
||||
if payload == self._written:
|
||||
return
|
||||
try:
|
||||
tmp = self.paths.agent_state_file.with_suffix(".tmp")
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
tmp.replace(self.paths.agent_state_file)
|
||||
self._written = payload
|
||||
except OSError as exc:
|
||||
log.debug("Kon agentstatus niet schrijven: %s", exc)
|
||||
|
||||
|
||||
def read_state(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logutil.setup()
|
||||
agent = Agent(load_paths())
|
||||
running = True
|
||||
|
||||
def stop(*_: Any) -> None:
|
||||
nonlocal running
|
||||
running = False
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
log.info("Kiosk-agent gestart")
|
||||
while running:
|
||||
try:
|
||||
agent.tick()
|
||||
except Exception: # never die on an unexpected page state; systemd would only restart us
|
||||
log.exception("Onverwachte fout in de kiosk-agent")
|
||||
for _ in range(TICK_SECONDS * 2):
|
||||
if not running:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
return 0
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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 socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from . import logutil, sysinfo
|
||||
from .config import admin_url, load_config
|
||||
from .paths import Paths, load_paths
|
||||
from .validation import mask_url, 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"], "url": mask_url(config["url"]),
|
||||
"admin": admin_url(config, socket.gethostname().split(".")[0])})
|
||||
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
|
||||
|
||||
|
||||
BOOT_GRACE_SECONDS = 180
|
||||
|
||||
|
||||
def prepare_display(paths: Paths, config: dict[str, Any]) -> tuple[int, int] | None:
|
||||
outputs: list[dict[str, Any]] = []
|
||||
# A monitor that powers up together with the Pi may need a moment to report
|
||||
# itself. Later starts (a browser restart without a screen) should not wait.
|
||||
attempts = 15 if (sysinfo.uptime_seconds() or 0) < BOOT_GRACE_SECONDS else 1
|
||||
for attempt in range(attempts):
|
||||
if attempt:
|
||||
time.sleep(2)
|
||||
proc = _run(["xrandr", "--query"])
|
||||
outputs = parse_xrandr(proc.stdout) if proc and proc.returncode == 0 else []
|
||||
if outputs:
|
||||
break
|
||||
size = None
|
||||
if outputs:
|
||||
output = outputs[0]
|
||||
proc = _run(xrandr_args(output, config))
|
||||
if proc is not None and proc.returncode != 0:
|
||||
log.warning("xrandr: %s", proc.stderr.strip())
|
||||
refreshed = _run(["xrandr", "--query"])
|
||||
if refreshed and refreshed.returncode == 0:
|
||||
outputs = parse_xrandr(refreshed.stdout) or outputs
|
||||
output = outputs[0]
|
||||
size = output.get("size")
|
||||
else:
|
||||
log.warning("Geen aangesloten scherm gevonden")
|
||||
for cmd in xset_commands(config):
|
||||
_run(cmd)
|
||||
info = {"outputs": outputs, "rotation": config.get("rotation", 0), "updated": int(time.time())}
|
||||
try:
|
||||
paths.display_info_file.write_text(json.dumps(info), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning("Kon schermgegevens niet opslaan: %s", exc)
|
||||
return size
|
||||
|
||||
|
||||
# -- profile -------------------------------------------------------------
|
||||
|
||||
def clean_profile(profile_dir: Path) -> None:
|
||||
"""Remove stale locks and the 'did not shut down correctly' flag left by a crash or power cut."""
|
||||
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||
try:
|
||||
(profile_dir / name).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("Kon %s niet verwijderen: %s", name, exc)
|
||||
prefs = profile_dir / "Default" / "Preferences"
|
||||
try:
|
||||
data = json.loads(prefs.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
profile = data.setdefault("profile", {})
|
||||
profile["exit_type"] = "Normal"
|
||||
profile["exited_cleanly"] = True
|
||||
tmp = prefs.with_suffix(".voxip-tmp")
|
||||
tmp.write_text(json.dumps(data), encoding="utf-8")
|
||||
os.replace(tmp, prefs)
|
||||
|
||||
|
||||
def profile_dirs(config: dict[str, Any]) -> tuple[Path, Path]:
|
||||
runtime = Path(os.environ.get("RUNTIME_DIRECTORY", "/run/voxip-kiosk-browser"))
|
||||
state = Path(os.environ.get("STATE_DIRECTORY", "/var/lib/voxip-kiosk-browser"))
|
||||
profile = state / "profile" if config.get("browser_profile") == "persistent" else runtime / "profile"
|
||||
if config.get("browser_profile") != "persistent" and (state / "profile").exists():
|
||||
shutil.rmtree(state / "profile", ignore_errors=True)
|
||||
cache = runtime / "cache"
|
||||
profile.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
return profile, cache
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logutil.setup()
|
||||
paths = load_paths()
|
||||
config = load_config(paths.config_file)
|
||||
if not wait_for_x():
|
||||
log.error("X-server niet bereikbaar")
|
||||
return 1
|
||||
size = prepare_display(paths, config)
|
||||
profile, cache = profile_dirs(config)
|
||||
clean_profile(profile)
|
||||
args = build_args(find_chromium(), config, profile, cache, start_page_url(paths, config), size)
|
||||
log.info("Chromium starten (profiel: %s, zoom: %s, rotatie: %s°)", config["browser_profile"], config["zoom"], config["rotation"])
|
||||
started = time.monotonic()
|
||||
code = subprocess.call(args)
|
||||
lifetime = time.monotonic() - started
|
||||
if config.get("browser_restart", True):
|
||||
log.warning("Chromium gestopt (exitcode %s na %.0f s); systemd start opnieuw", code, lifetime)
|
||||
return 1
|
||||
log.warning("Chromium gestopt (exitcode %s); automatisch herstarten staat uit", code)
|
||||
return 0
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Minimal Chrome DevTools Protocol client for the local kiosk browser.
|
||||
|
||||
Chromium listens on 127.0.0.1 only (``--remote-debugging-address``); this
|
||||
client refuses any other host. Each call opens a short-lived WebSocket to
|
||||
the page target, which keeps the client stateless and robust against
|
||||
browser restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import itertools
|
||||
import json
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_PORT = 9222
|
||||
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
class CDPError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CDP:
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT, timeout: float = 5.0):
|
||||
if host not in _LOCAL_HOSTS:
|
||||
raise ValueError("DevTools is alleen lokaal bereikbaar")
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self._ids = itertools.count(1)
|
||||
|
||||
# -- HTTP endpoints -------------------------------------------------
|
||||
|
||||
def _http_json(self, path: str, method: str = "GET") -> Any:
|
||||
req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise CDPError(f"DevTools niet bereikbaar: {exc}") from exc
|
||||
|
||||
def version(self) -> dict[str, Any]:
|
||||
return self._http_json("/json/version")
|
||||
|
||||
def pages(self) -> list[dict[str, Any]]:
|
||||
return [t for t in self._http_json("/json/list") if t.get("type") == "page"]
|
||||
|
||||
def main_page(self) -> dict[str, Any] | None:
|
||||
pages = self.pages()
|
||||
return pages[0] if pages else None
|
||||
|
||||
def new_page(self, url: str) -> dict[str, Any]:
|
||||
from urllib.parse import quote
|
||||
|
||||
return self._http_json("/json/new?" + quote(url, safe=""), method="PUT")
|
||||
|
||||
# -- WebSocket commands ---------------------------------------------
|
||||
|
||||
def call(self, ws_url: str, method: str, params: dict[str, Any] | None = None, timeout: float | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
import websocket # websocket-client (python3-websocket)
|
||||
except ImportError as exc: # pragma: no cover - dependency missing
|
||||
raise CDPError("python3-websocket is niet geïnstalleerd") from exc
|
||||
|
||||
msg_id = next(self._ids)
|
||||
try:
|
||||
# Chromium rejects DevTools WebSockets that carry a foreign Origin;
|
||||
# we send none at all.
|
||||
ws = websocket.create_connection(ws_url, timeout=timeout or self.timeout, suppress_origin=True)
|
||||
except Exception as exc:
|
||||
raise CDPError(f"Verbinding met DevTools mislukt: {exc}") from exc
|
||||
try:
|
||||
ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
|
||||
while True:
|
||||
reply = json.loads(ws.recv())
|
||||
if reply.get("id") == msg_id:
|
||||
break
|
||||
except Exception as exc:
|
||||
raise CDPError(f"DevTools-opdracht {method} mislukt: {exc}") from exc
|
||||
finally:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if "error" in reply:
|
||||
raise CDPError(f"{method}: {reply['error'].get('message', reply['error'])}")
|
||||
return reply.get("result", {})
|
||||
|
||||
def _page_ws(self) -> str:
|
||||
page = self.main_page()
|
||||
if not page or not page.get("webSocketDebuggerUrl"):
|
||||
raise CDPError("Geen browserpagina gevonden")
|
||||
return page["webSocketDebuggerUrl"]
|
||||
|
||||
def current_url(self) -> str | None:
|
||||
page = self.main_page()
|
||||
return page.get("url") if page else None
|
||||
|
||||
def navigate(self, url: str) -> None:
|
||||
self.call(self._page_ws(), "Page.navigate", {"url": url})
|
||||
|
||||
def reload(self, ignore_cache: bool = False) -> None:
|
||||
self.call(self._page_ws(), "Page.reload", {"ignoreCache": ignore_cache})
|
||||
|
||||
def responsive(self, ws_url: str | None = None, timeout: float = 5.0) -> bool:
|
||||
"""True when the page's renderer still executes JavaScript."""
|
||||
try:
|
||||
result = self.call(ws_url or self._page_ws(), "Runtime.evaluate", {"expression": "1+1", "returnByValue": True}, timeout=timeout)
|
||||
except CDPError:
|
||||
return False
|
||||
return result.get("result", {}).get("value") == 2
|
||||
|
||||
def screenshot(self, quality: int = 70) -> bytes:
|
||||
result = self.call(self._page_ws(), "Page.captureScreenshot", {"format": "jpeg", "quality": quality}, timeout=15)
|
||||
try:
|
||||
return base64.b64decode(result["data"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise CDPError("Ongeldige screenshot ontvangen") from exc
|
||||
|
||||
def close_browser(self) -> None:
|
||||
ws_url = self.version().get("webSocketDebuggerUrl")
|
||||
if not ws_url:
|
||||
raise CDPError("Geen browser-endpoint gevonden")
|
||||
self.call(ws_url, "Browser.close")
|
||||
@@ -0,0 +1,257 @@
|
||||
"""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": "Daynivo 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,
|
||||
"auto_update": 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,
|
||||
"auto_update": _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 admin_url(config: dict[str, Any], hostname: str) -> str:
|
||||
"""Address of the web interface as shown on the kiosk screen."""
|
||||
scheme = "https" if config["admin_https"] else "http"
|
||||
return f"{scheme}://{hostname}.local:{config['admin_port']}"
|
||||
|
||||
|
||||
def validate(updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate known keys in ``updates``; raise ConfigError listing every problem."""
|
||||
clean: dict[str, Any] = {}
|
||||
errors: dict[str, str] = {}
|
||||
for key, value in updates.items():
|
||||
check = VALIDATORS.get(key)
|
||||
if check is None:
|
||||
errors[key] = "Onbekende instelling."
|
||||
continue
|
||||
try:
|
||||
clean[key] = check(value)
|
||||
except v.ValidationError as exc:
|
||||
errors[key] = str(exc)
|
||||
if errors:
|
||||
raise ConfigError(errors)
|
||||
return clean
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError) as exc:
|
||||
log.warning("Kan %s niet lezen: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
log.warning("%s bevat geen JSON-object", path)
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _read_raw(path: Path) -> dict[str, Any]:
|
||||
data = _read_json(path)
|
||||
if data is None and path.exists():
|
||||
data = _read_json(path.with_suffix(path.suffix + ".bak"))
|
||||
if data is not None:
|
||||
log.warning("Configuratie hersteld uit %s.bak", path.name)
|
||||
return data or {}
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
"""Effective configuration: defaults overlaid with every valid stored value."""
|
||||
raw = _read_raw(path)
|
||||
config = copy.deepcopy(DEFAULTS)
|
||||
for key, value in raw.items():
|
||||
check = VALIDATORS.get(key)
|
||||
if check is None:
|
||||
continue
|
||||
try:
|
||||
config[key] = check(value)
|
||||
except v.ValidationError as exc:
|
||||
log.warning("Ongeldige waarde voor %s in %s (%s); standaard gebruikt", key, path, exc)
|
||||
return config
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes, mode: int = 0o640, owner: tuple[int, int] | None = None) -> None:
|
||||
"""Write ``content`` to ``path`` so readers see either the old or the new file, never a partial one."""
|
||||
path = Path(path)
|
||||
if owner is None and path.exists():
|
||||
st = path.stat()
|
||||
owner = (st.st_uid, st.st_gid)
|
||||
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(content)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(tmp, mode)
|
||||
if owner is not None and os.geteuid() == 0:
|
||||
os.chown(tmp, *owner)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, data: Any, mode: int = 0o640, owner: tuple[int, int] | None = None) -> None:
|
||||
content = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
atomic_write(path, content, mode=mode, owner=owner)
|
||||
|
||||
|
||||
_thread_lock = threading.Lock()
|
||||
|
||||
|
||||
def save_config(path: Path, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate ``updates``, merge them into the stored file and write it atomically.
|
||||
|
||||
Unknown keys already in the file are preserved; the previous version is
|
||||
kept as ``config.json.bak``. Returns the new effective configuration.
|
||||
"""
|
||||
clean = validate(updates)
|
||||
path = Path(path)
|
||||
lock_path = path.with_name(".config.lock")
|
||||
with _thread_lock:
|
||||
# Read-only is enough for flock and works for every writer (the web
|
||||
# interface as voxip, the helper as root), whoever created the file.
|
||||
lock_fd = os.open(str(lock_path), os.O_RDONLY | os.O_CREAT, 0o640)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
return _save_locked(path, clean)
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
def _save_locked(path: Path, clean: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = _read_raw(path)
|
||||
if path.exists():
|
||||
try:
|
||||
atomic_write(path.with_suffix(path.suffix + ".bak"), path.read_bytes())
|
||||
except OSError as exc:
|
||||
log.warning("Kon geen back-up van de configuratie maken: %s", exc)
|
||||
raw.update(clean)
|
||||
atomic_write_json(path, raw)
|
||||
return load_config(path)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""daynivo-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"Daynivo 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 daynivo-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="daynivo-kiosk-ctl", description="Beheer van Daynivo Kiosk OS vanaf de commandoregel.")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
p = sub.add_parser("status", help="toon de status")
|
||||
p.add_argument("--show-secrets", action="store_true", help="toon de volledige URL inclusief token")
|
||||
p.set_defaults(func=cmd_status)
|
||||
sub.add_parser("setup-code", help="toon de setupcode voor de eerste configuratie").set_defaults(func=cmd_setup_code)
|
||||
p = sub.add_parser("reset-password", help="verwijder het beheerderswachtwoord (nieuwe setupcode)")
|
||||
p.add_argument("-y", "--yes", action="store_true")
|
||||
p.set_defaults(func=cmd_reset_password)
|
||||
p = sub.add_parser("set-url", help="stel de kiosk-URL in")
|
||||
p.add_argument("url")
|
||||
p.set_defaults(func=cmd_set_url)
|
||||
sub.add_parser("reload", help="herlaad de pagina").set_defaults(func=cmd_reload)
|
||||
sub.add_parser("restart-browser", help="herstart Chromium").set_defaults(func=cmd_restart_browser)
|
||||
sub.add_parser("health", help="healthcheck-rapport (zonder herstelacties)").set_defaults(func=cmd_health)
|
||||
p = sub.add_parser("maintenance", help="onderhoudsmodus aan/uit")
|
||||
p.add_argument("state", choices=("on", "off"))
|
||||
p.set_defaults(func=cmd_maintenance)
|
||||
p = sub.add_parser("config", help="toon de configuratie")
|
||||
p.add_argument("--show-secrets", action="store_true")
|
||||
p.set_defaults(func=cmd_config)
|
||||
sub.add_parser("version", help="toon de versie").set_defaults(func=cmd_version)
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""voxip-kiosk-healthcheck — runs every minute (timer) as root.
|
||||
|
||||
Keeps every kiosk service alive without hiding problems: each recovery is
|
||||
logged, and a crash loop that hit systemd's start limit is retried only
|
||||
after a cool-down, so the journal shows the loop instead of an endless
|
||||
stream of restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import logutil, services, sysinfo
|
||||
from .cdp import CDP, CDPError
|
||||
from .config import load_config
|
||||
from .paths import Paths, load_paths
|
||||
|
||||
log = logging.getLogger("voxip-kiosk-healthcheck")
|
||||
|
||||
ALWAYS_ON = (services.AGENT_UNIT, services.MANAGER_UNIT)
|
||||
DISPLAY_UNITS = (services.X_UNIT, services.BROWSER_UNIT)
|
||||
DISPLAY_RETRY_AFTER = 300
|
||||
CDP_GRACE = 90
|
||||
CDP_FAILURES_BEFORE_RESTART = 3
|
||||
WARN_EVERY = 3600
|
||||
|
||||
|
||||
def _systemctl(*args: str) -> None:
|
||||
try:
|
||||
subprocess.run(["systemctl", *args], capture_output=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
log.error("systemctl %s mislukt: %s", " ".join(args), exc)
|
||||
|
||||
|
||||
class HealthCheck:
|
||||
def __init__(self, paths: Paths, unit_status=services.unit_status, systemctl=_systemctl, cdp: Any = None,
|
||||
throttled=sysinfo.throttled, disk=sysinfo.disk, now=time.time):
|
||||
self.paths = paths
|
||||
self.unit_status = unit_status
|
||||
self.systemctl = systemctl
|
||||
self.cdp = cdp or CDP(timeout=4)
|
||||
self.throttled = throttled
|
||||
self.disk = disk
|
||||
self.now = now
|
||||
|
||||
def load_state(self) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(self.paths.health_state_file.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
def save_state(self, state: dict[str, Any]) -> None:
|
||||
try:
|
||||
self.paths.health_state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.paths.health_state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning("Kon healthcheck-status niet opslaan: %s", exc)
|
||||
|
||||
def maintenance(self) -> bool:
|
||||
return (self.paths.health_run_dir / "maintenance").exists()
|
||||
|
||||
def run(self, act: bool = True) -> dict[str, Any]:
|
||||
now = self.now()
|
||||
state = self.load_state()
|
||||
failed_since: dict[str, float] = state.setdefault("failed_since", {})
|
||||
warned: dict[str, float] = state.setdefault("warned", {})
|
||||
config = load_config(self.paths.config_file)
|
||||
report: dict[str, Any] = {"units": {}, "actions": [], "maintenance": self.maintenance()}
|
||||
act = act and not report["maintenance"]
|
||||
|
||||
for unit in (*DISPLAY_UNITS, *ALWAYS_ON):
|
||||
props = self.unit_status(unit)
|
||||
active = props.get("ActiveState", "unknown")
|
||||
report["units"][unit] = active
|
||||
is_display = unit in DISPLAY_UNITS
|
||||
if active == "failed":
|
||||
if unit not in failed_since:
|
||||
failed_since[unit] = now
|
||||
log.error("%s is in fouttoestand (resultaat: %s, herstarts: %s)", unit, props.get("Result"), props.get("NRestarts"))
|
||||
if is_display and not config["browser_restart"]:
|
||||
continue
|
||||
wait = DISPLAY_RETRY_AFTER if is_display else 0
|
||||
if act and now - failed_since[unit] >= wait:
|
||||
log.warning("%s opnieuw starten na fouttoestand", unit)
|
||||
self.systemctl("reset-failed", unit)
|
||||
self.systemctl("start", "--no-block", unit)
|
||||
report["actions"].append(f"start {unit}")
|
||||
del failed_since[unit]
|
||||
elif active == "inactive":
|
||||
failed_since.pop(unit, None)
|
||||
should_run = not is_display or config["browser_restart"]
|
||||
if act and should_run:
|
||||
log.warning("%s draaide niet; wordt gestart", unit)
|
||||
self.systemctl("start", "--no-block", unit)
|
||||
report["actions"].append(f"start {unit}")
|
||||
else:
|
||||
failed_since.pop(unit, None)
|
||||
|
||||
self._check_devtools(state, report, now, act)
|
||||
self._check_hardware(warned, report, now)
|
||||
self.save_state(state)
|
||||
return report
|
||||
|
||||
def _check_devtools(self, state: dict[str, Any], report: dict[str, Any], now: float, act: bool) -> None:
|
||||
if report["units"].get(services.BROWSER_UNIT) != "active":
|
||||
state.pop("browser_active_since", None)
|
||||
state["cdp_failures"] = 0
|
||||
return
|
||||
since = state.setdefault("browser_active_since", now)
|
||||
if now - since < CDP_GRACE:
|
||||
return
|
||||
try:
|
||||
self.cdp.version()
|
||||
state["cdp_failures"] = 0
|
||||
report["devtools"] = "ok"
|
||||
except CDPError:
|
||||
state["cdp_failures"] = state.get("cdp_failures", 0) + 1
|
||||
report["devtools"] = "geen antwoord"
|
||||
log.warning("Chromium reageert niet op DevTools (%s keer achter elkaar)", state["cdp_failures"])
|
||||
if act and state["cdp_failures"] >= CDP_FAILURES_BEFORE_RESTART:
|
||||
log.error("Chromium wordt herstart: DevTools reageert niet")
|
||||
self.systemctl("restart", "--no-block", services.BROWSER_UNIT)
|
||||
report["actions"].append(f"restart {services.BROWSER_UNIT}")
|
||||
state["cdp_failures"] = 0
|
||||
state.pop("browser_active_since", None)
|
||||
|
||||
def _check_hardware(self, warned: dict[str, float], report: dict[str, Any], now: float) -> None:
|
||||
throttle = self.throttled()
|
||||
if throttle and throttle["now"]:
|
||||
report["throttled"] = throttle["flags"]
|
||||
if now - warned.get("throttled", 0) >= WARN_EVERY:
|
||||
log.warning("Voeding/temperatuur: %s — controleer de voedingsadapter", ", ".join(throttle["flags"]))
|
||||
warned["throttled"] = now
|
||||
usage = self.disk("/")
|
||||
if usage and usage["percent"] >= 90:
|
||||
report["disk_percent"] = usage["percent"]
|
||||
if now - warned.get("disk", 0) >= WARN_EVERY:
|
||||
log.warning("SD-kaart is voor %s%% vol", usage["percent"])
|
||||
warned["disk"] = now
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="voxip-kiosk-healthcheck")
|
||||
parser.add_argument("--check-only", action="store_true", help="alleen rapporteren, niets herstellen")
|
||||
parser.add_argument("--json", action="store_true", help="rapport als JSON")
|
||||
args = parser.parse_args(argv)
|
||||
logutil.setup()
|
||||
report = HealthCheck(load_paths()).run(act=not args.check_only)
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
units = ", ".join(f"{Path(u).stem}={s}" for u, s in report["units"].items())
|
||||
log.info("Healthcheck: %s%s", units, f"; acties: {', '.join(report['actions'])}" if report["actions"] else "")
|
||||
return 0
|
||||
@@ -0,0 +1,474 @@
|
||||
"""voxip-kiosk-helper — the only privileged entry point of Daynivo 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, updater
|
||||
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"
|
||||
UPDATE_UNIT = "voxip-kiosk-update-now"
|
||||
UPDATER_PATH = "/usr/lib/voxip-kiosk/voxip-kiosk-updater"
|
||||
ACTIVATE_UNIT = "voxip-network-activate"
|
||||
BOOT_DIRS = (Path("/boot/firmware"), Path("/boot"))
|
||||
# Files on the boot partition. The voxip-* names are from before the rename to
|
||||
# Daynivo and keep working, so existing SD cards and instructions stay valid.
|
||||
RESET_TRIGGERS = ("daynivo-network-reset", "voxip-network-reset")
|
||||
IMPORT_CONFIGS = ("voxip-config.json", "daynivo-config.json") # applied in this order: the Daynivo name wins
|
||||
IMPORT_KEYS = ("voxip-authorized_keys", "daynivo-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",
|
||||
"update-check", "update-install",
|
||||
}
|
||||
|
||||
|
||||
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."}
|
||||
|
||||
# -- software updates -----------------------------------------------
|
||||
|
||||
def update_check(self) -> dict[str, Any]:
|
||||
state, _ = updater.Updater(self.paths).check()
|
||||
return {"update": state}
|
||||
|
||||
def update_install(self) -> dict[str, Any]:
|
||||
# A transient unit of its own: install.sh restarts the web interface, which
|
||||
# must not take the running update down with it.
|
||||
proc = self.run(["systemd-run", f"--unit={UPDATE_UNIT}", "--collect", "--quiet",
|
||||
UPDATER_PATH, "--install"], check=False)
|
||||
if proc.returncode != 0:
|
||||
raise HelperError("Er loopt al een update, of die kon niet worden gestart.")
|
||||
log(syslog.LOG_NOTICE, "Update gestart via beheerinterface")
|
||||
return {"message": "De update is gestart. De beheerinterface is zo meteen even niet bereikbaar."}
|
||||
|
||||
# -- 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()
|
||||
triggers = [boot / name for name in RESET_TRIGGERS if (boot / name).exists()] if boot else []
|
||||
if triggers:
|
||||
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))
|
||||
for trigger in triggers:
|
||||
trigger.unlink()
|
||||
log(syslog.LOG_WARNING, f"Netwerk-reset uitgevoerd; Daynivo-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 = []
|
||||
for name in IMPORT_CONFIGS:
|
||||
conf = boot / name
|
||||
if not conf.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(conf.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(data, dict):
|
||||
raise HelperError(f"{name} 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(name + ".applied"))
|
||||
imported.append(name)
|
||||
log(syslog.LOG_NOTICE, f"Configuratie geïmporteerd vanaf de bootpartitie ({name})")
|
||||
except (HelperError, cfg.ConfigError, v.ValidationError, ValueError, OSError) as exc:
|
||||
(boot / name.replace(".json", ".error.txt")).write_text(f"{exc}\n", encoding="utf-8")
|
||||
conf.rename(conf.with_name(name + ".rejected"))
|
||||
log(syslog.LOG_ERR, f"{name} geweigerd: {exc}")
|
||||
for name in IMPORT_KEYS:
|
||||
keys = boot / name
|
||||
if not keys.exists():
|
||||
continue
|
||||
try:
|
||||
self._import_ssh_keys(keys.read_text(encoding="utf-8-sig"))
|
||||
keys.rename(keys.with_name(name + ".applied"))
|
||||
imported.append(name)
|
||||
except (HelperError, OSError) as exc:
|
||||
keys.rename(keys.with_name(name + ".rejected"))
|
||||
log(syslog.LOG_ERR, f"SSH-sleutels geweigerd ({name}): {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 Daynivo 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],
|
||||
"update-check": lambda: (no_args(), self.update_check())[1],
|
||||
"update-install": lambda: (no_args(), self.update_install())[1],
|
||||
}
|
||||
if verb not in table:
|
||||
raise HelperError(f"Onbekende opdracht: {verb}")
|
||||
return table[verb]()
|
||||
|
||||
|
||||
def _parse_json(text: str) -> Any:
|
||||
if len(text) > 16384:
|
||||
raise HelperError("Invoer te groot")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise HelperError("Ongeldige JSON-invoer") from exc
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, helper: Helper | None = None, environ: dict[str, str] | None = None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
environ = os.environ if environ is None else environ
|
||||
syslog.openlog("voxip-kiosk-helper", 0, syslog.LOG_DAEMON)
|
||||
if not argv:
|
||||
print(json.dumps({"ok": False, "error": "Gebruik: voxip-kiosk-helper <opdracht> [argumenten]"}))
|
||||
return 2
|
||||
verb, args = argv[0], argv[1:]
|
||||
caller = environ.get("SUDO_USER")
|
||||
if caller and verb not in PUBLIC_VERBS:
|
||||
log(syslog.LOG_WARNING, f"Geweigerd: {caller} probeerde interne opdracht {verb}")
|
||||
print(json.dumps({"ok": False, "error": "Opdracht niet toegestaan"}))
|
||||
return 1
|
||||
if helper is None and os.geteuid() != 0:
|
||||
print(json.dumps({"ok": False, "error": "voxip-kiosk-helper moet als root draaien"}))
|
||||
return 1
|
||||
helper = helper or Helper()
|
||||
stdin = sys.stdin.read(16385) if verb == "network-apply" else ""
|
||||
try:
|
||||
result = helper.dispatch(verb, args, stdin)
|
||||
except (HelperError, v.ValidationError, cfg.ConfigError) as exc:
|
||||
log(syslog.LOG_WARNING, f"{verb} mislukt: {exc}")
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
return 1
|
||||
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
|
||||
return 0
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Calls the root helper through ``sudo -n`` with a fixed argument list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HelperError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HelperClient:
|
||||
def __init__(self, helper_path: Path):
|
||||
self.helper_path = str(helper_path)
|
||||
|
||||
def call(self, verb: str, *args: str, payload: Any = None, timeout: float = 60) -> dict[str, Any]:
|
||||
cmd = ["sudo", "-n", self.helper_path, verb, *args]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, input=json.dumps(payload) if payload is not None else "",
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HelperError("De opdracht duurde te lang.") from None
|
||||
except OSError as exc:
|
||||
raise HelperError(f"Systeemhelper niet beschikbaar: {exc}") from None
|
||||
try:
|
||||
result = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
except (ValueError, IndexError):
|
||||
detail = (proc.stderr or "").strip().splitlines()
|
||||
raise HelperError(detail[-1] if detail else "Onverwacht antwoord van de systeemhelper.") from None
|
||||
if not result.get("ok"):
|
||||
raise HelperError(result.get("error") or "Opdracht mislukt.")
|
||||
return result
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Logging to stdout with sd-daemon priority prefixes, so journald records
|
||||
warnings and errors at the right level (``journalctl -p warning`` works)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_PRIORITY = {logging.DEBUG: 7, logging.INFO: 6, logging.WARNING: 4, logging.ERROR: 3, logging.CRITICAL: 2}
|
||||
|
||||
|
||||
class JournalFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
prio = _PRIORITY.get(record.levelno, 6)
|
||||
return "\n".join(f"<{prio}>{line}" for line in super().format(record).splitlines() or [""])
|
||||
|
||||
|
||||
def setup(level: int = logging.INFO) -> None:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JournalFormatter("%(message)s"))
|
||||
root = logging.getLogger()
|
||||
root.handlers[:] = [handler]
|
||||
root.setLevel(level)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""voxip-kiosk-manager — starts the web interface under gunicorn.
|
||||
|
||||
Reads port and HTTPS settings from the kiosk configuration, creates a
|
||||
self-signed certificate when HTTPS is enabled and none exists yet, then
|
||||
replaces itself with gunicorn (one worker so the login throttle is shared).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import logutil
|
||||
from .config import load_config
|
||||
from .paths import Paths, load_paths
|
||||
|
||||
log = logging.getLogger("voxip-kiosk-manager")
|
||||
PYTHON_LIB = "/usr/lib/voxip-kiosk/python"
|
||||
|
||||
|
||||
def ensure_certificate(paths: Paths) -> tuple[Path, Path]:
|
||||
cert, key = paths.tls_dir / "cert.pem", paths.tls_dir / "key.pem"
|
||||
if cert.exists() and key.exists():
|
||||
return cert, key
|
||||
paths.tls_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
host = socket.gethostname()
|
||||
log.info("Zelfondertekend certificaat aanmaken voor %s.local", host)
|
||||
subprocess.run([
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650",
|
||||
"-subj", f"/CN={host}.local/O=Daynivo 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("daynivo_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("Daynivo Kiosk Manager start op poort %s (%s)", config["admin_port"], "HTTPS" if config["admin_https"] else "HTTP")
|
||||
os.execv(argv[0], argv)
|
||||
return 1 # not reached
|
||||
@@ -0,0 +1,181 @@
|
||||
"""NetworkManager configuration model used by the root helper.
|
||||
|
||||
The helper writes keyfiles itself instead of passing settings to ``nmcli``
|
||||
on the command line: secrets never show up in the process list, and a
|
||||
rollback is a plain file restore.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import io
|
||||
import uuid as uuidlib
|
||||
from typing import Any
|
||||
|
||||
from . import validation as v
|
||||
|
||||
INTERFACES = {"eth0": "ethernet", "wlan0": "wifi"}
|
||||
PROFILE_PREFIX = "voxip-"
|
||||
ROLLBACK_SECONDS = 180
|
||||
|
||||
|
||||
def profile_name(interface: str) -> str:
|
||||
return PROFILE_PREFIX + interface
|
||||
|
||||
|
||||
def validate_request(payload: Any) -> dict[str, Any]:
|
||||
"""Validate a network change request coming from the web interface."""
|
||||
if not isinstance(payload, dict):
|
||||
raise v.ValidationError("Ongeldig verzoek.")
|
||||
interface = payload.get("interface")
|
||||
if interface not in INTERFACES:
|
||||
raise v.ValidationError("Onbekende netwerkinterface.")
|
||||
method = payload.get("method")
|
||||
if method not in ("dhcp", "static"):
|
||||
raise v.ValidationError("Kies DHCP of een statisch adres.")
|
||||
clean: dict[str, Any] = {"interface": interface, "method": method}
|
||||
if method == "static":
|
||||
clean["address"] = v.ipv4_interface(str(payload.get("address", "")))
|
||||
gateway = str(payload.get("gateway", "")).strip()
|
||||
if gateway:
|
||||
import ipaddress
|
||||
|
||||
gw = v.ipv4_address(gateway)
|
||||
if ipaddress.IPv4Address(gw) not in ipaddress.IPv4Interface(clean["address"]).network:
|
||||
raise v.ValidationError("De gateway ligt niet in hetzelfde subnet als het IP-adres.")
|
||||
clean["gateway"] = gw
|
||||
else:
|
||||
clean["gateway"] = ""
|
||||
dns = payload.get("dns", [])
|
||||
if not isinstance(dns, (list, str)):
|
||||
raise v.ValidationError("Ongeldige DNS-servers.")
|
||||
clean["dns"] = v.dns_servers(dns)
|
||||
if method == "static" and not clean["dns"]:
|
||||
raise v.ValidationError("Vul bij een statisch adres minimaal één DNS-server in.")
|
||||
if interface == "wlan0":
|
||||
wifi = payload.get("wifi")
|
||||
if not isinstance(wifi, dict):
|
||||
raise v.ValidationError("Wifi-gegevens ontbreken.")
|
||||
psk = wifi.get("psk") or ""
|
||||
clean["wifi"] = {
|
||||
"ssid": v.ssid(str(wifi.get("ssid", ""))),
|
||||
"psk": v.wifi_psk(psk) if psk else "",
|
||||
"hidden": bool(wifi.get("hidden", False)),
|
||||
"open": bool(wifi.get("open", False)),
|
||||
}
|
||||
return clean
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
"""Escape a value for a GLib key file (the format NetworkManager uses)."""
|
||||
out = value.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r")
|
||||
if out.startswith(" "):
|
||||
out = "\\s" + out[1:]
|
||||
return out
|
||||
|
||||
|
||||
def build_keyfile(req: dict[str, Any], uuid: str | None = None, psk: str = "") -> str:
|
||||
"""Render a NetworkManager keyfile for a validated request."""
|
||||
interface = req["interface"]
|
||||
name = profile_name(interface)
|
||||
lines = [
|
||||
"[connection]",
|
||||
f"id={name}",
|
||||
f"uuid={uuid or uuidlib.uuid4()}",
|
||||
f"type={INTERFACES[interface]}",
|
||||
f"interface-name={interface}",
|
||||
"autoconnect=true",
|
||||
"autoconnect-priority=100",
|
||||
"",
|
||||
]
|
||||
if interface == "wlan0":
|
||||
wifi = req["wifi"]
|
||||
lines += ["[wifi]", "mode=infrastructure", f"ssid={_escape(wifi['ssid'])}", f"hidden={'true' if wifi['hidden'] else 'false'}", ""]
|
||||
if not wifi["open"]:
|
||||
if not psk:
|
||||
raise v.ValidationError("Vul het wifi-wachtwoord in.")
|
||||
lines += ["[wifi-security]", "key-mgmt=wpa-psk", f"psk={_escape(psk)}", ""]
|
||||
else:
|
||||
lines += ["[ethernet]", ""]
|
||||
lines.append("[ipv4]")
|
||||
if req["method"] == "static":
|
||||
lines.append("method=manual")
|
||||
address = req["address"]
|
||||
lines.append(f"address1={address},{req['gateway']}" if req.get("gateway") else f"address1={address}")
|
||||
else:
|
||||
lines.append("method=auto")
|
||||
if req["dns"]:
|
||||
ipv4_dns = [d for d in req["dns"] if ":" not in d]
|
||||
if ipv4_dns:
|
||||
lines.append("dns=" + ";".join(ipv4_dns) + ";")
|
||||
lines.append("ignore-auto-dns=true")
|
||||
lines += ["", "[ipv6]", "addr-gen-mode=default", "method=auto"]
|
||||
ipv6_dns = [d for d in req["dns"] if ":" in d]
|
||||
if ipv6_dns:
|
||||
lines.append("dns=" + ";".join(ipv6_dns) + ";")
|
||||
lines += ["", "[proxy]", ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_keyfile(text: str) -> configparser.ConfigParser:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = str # keep key case
|
||||
parser.read_file(io.StringIO(text))
|
||||
return parser
|
||||
|
||||
|
||||
def unescape(value: str) -> str:
|
||||
out, i = [], 0
|
||||
mapping = {"\\": "\\", "n": "\n", "t": "\t", "r": "\r", "s": " "}
|
||||
while i < len(value):
|
||||
ch = value[i]
|
||||
if ch == "\\" and i + 1 < len(value) and value[i + 1] in mapping:
|
||||
out.append(mapping[value[i + 1]])
|
||||
i += 2
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def split_terse(line: str) -> list[str]:
|
||||
"""Split one line of ``nmcli -t`` output, honouring ``\\:`` and ``\\\\`` escapes."""
|
||||
fields, current, i = [], [], 0
|
||||
while i < len(line):
|
||||
ch = line[i]
|
||||
if ch == "\\" and i + 1 < len(line):
|
||||
current.append(line[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == ":":
|
||||
fields.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(ch)
|
||||
i += 1
|
||||
fields.append("".join(current))
|
||||
return fields
|
||||
|
||||
|
||||
def parse_wifi_scan(output: str) -> list[dict[str, Any]]:
|
||||
"""Parse ``nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list``."""
|
||||
networks: dict[str, dict[str, Any]] = {}
|
||||
for line in output.splitlines():
|
||||
fields = split_terse(line)
|
||||
if len(fields) < 4:
|
||||
continue
|
||||
in_use, ssid, signal, security = fields[:4]
|
||||
if not ssid:
|
||||
continue
|
||||
try:
|
||||
strength = int(signal)
|
||||
except ValueError:
|
||||
strength = 0
|
||||
entry = {"ssid": ssid, "signal": strength, "security": security.strip() or "open", "in_use": in_use.strip() == "*"}
|
||||
current = networks.get(ssid)
|
||||
if current is None or strength > current["signal"]:
|
||||
entry["in_use"] = entry["in_use"] or bool(current and current["in_use"])
|
||||
networks[ssid] = entry
|
||||
elif entry["in_use"]:
|
||||
current["in_use"] = True
|
||||
return sorted(networks.values(), key=lambda n: (-n["in_use"], -n["signal"], n["ssid"].lower()))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Network status for the web interface (read-only, no privileges needed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .netconfig import INTERFACES, profile_name, split_terse
|
||||
|
||||
STATE_LABELS = {
|
||||
"connected": "Verbonden",
|
||||
"connecting": "Bezig met verbinden",
|
||||
"disconnected": "Niet verbonden",
|
||||
"unavailable": "Niet beschikbaar",
|
||||
"unmanaged": "Niet beheerd",
|
||||
}
|
||||
|
||||
|
||||
def _nmcli(*args: str) -> str | None:
|
||||
if not shutil.which("nmcli"):
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(["nmcli", *args], capture_output=True, text=True, timeout=10)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return proc.stdout if proc.returncode == 0 else None
|
||||
|
||||
|
||||
def device_details(device: str) -> dict[str, Any]:
|
||||
out = _nmcli("-t", "device", "show", device)
|
||||
info: dict[str, Any] = {"addresses": [], "dns": [], "gateway": "", "hwaddr": "", "state": "", "connection": ""}
|
||||
if not out:
|
||||
return info
|
||||
for line in out.splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
value = value.replace("\\:", ":")
|
||||
base = key.split("[", 1)[0]
|
||||
if base == "IP4.ADDRESS" and value:
|
||||
info["addresses"].append(value)
|
||||
elif base == "IP4.DNS" and value:
|
||||
info["dns"].append(value)
|
||||
elif base == "IP4.GATEWAY" and value and value != "--":
|
||||
info["gateway"] = value
|
||||
elif base == "GENERAL.HWADDR":
|
||||
info["hwaddr"] = value
|
||||
elif base == "GENERAL.STATE":
|
||||
info["state"] = re.sub(r"^\d+\s*", "", value).strip("() ")
|
||||
elif base == "GENERAL.CONNECTION" and value != "--":
|
||||
info["connection"] = value
|
||||
return info
|
||||
|
||||
|
||||
def profile_settings(interface: str) -> dict[str, Any]:
|
||||
"""Current settings of our own profile, used to pre-fill the forms."""
|
||||
out = _nmcli("-t", "-f", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,802-11-wireless.ssid,802-11-wireless.hidden",
|
||||
"connection", "show", profile_name(interface))
|
||||
settings = {"exists": False, "method": "dhcp", "address": "", "gateway": "", "dns": "", "ssid": "", "hidden": False}
|
||||
if not out:
|
||||
return settings
|
||||
settings["exists"] = True
|
||||
for line in out.splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
value = value.replace("\\:", ":")
|
||||
if value == "--":
|
||||
value = ""
|
||||
if key == "ipv4.method":
|
||||
settings["method"] = "static" if value == "manual" else "dhcp"
|
||||
elif key == "ipv4.addresses":
|
||||
settings["address"] = value.split(",")[0].strip()
|
||||
elif key == "ipv4.gateway":
|
||||
settings["gateway"] = value
|
||||
elif key == "ipv4.dns":
|
||||
settings["dns"] = ", ".join(x for x in value.split(",") if x)
|
||||
elif key == "802-11-wireless.ssid":
|
||||
settings["ssid"] = value
|
||||
elif key == "802-11-wireless.hidden":
|
||||
settings["hidden"] = value == "yes"
|
||||
return settings
|
||||
|
||||
|
||||
def overview() -> dict[str, Any]:
|
||||
available = shutil.which("nmcli") is not None
|
||||
devices = {}
|
||||
status = _nmcli("-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status") or ""
|
||||
present = {split_terse(line)[0]: split_terse(line) for line in status.splitlines() if line}
|
||||
for iface, kind in INTERFACES.items():
|
||||
fields = present.get(iface)
|
||||
details = device_details(iface) if fields else {}
|
||||
state = fields[2] if fields and len(fields) > 2 else ""
|
||||
devices[iface] = {
|
||||
"interface": iface,
|
||||
"kind": kind,
|
||||
"present": fields is not None,
|
||||
"state": state,
|
||||
"state_label": STATE_LABELS.get(state, state or "Niet aanwezig"),
|
||||
"connected": state == "connected",
|
||||
"connection": fields[3] if fields and len(fields) > 3 and fields[3] != "--" else "",
|
||||
"addresses": details.get("addresses", []),
|
||||
"gateway": details.get("gateway", ""),
|
||||
"dns": details.get("dns", []),
|
||||
"hwaddr": details.get("hwaddr", ""),
|
||||
"profile": profile_settings(iface) if available else {},
|
||||
}
|
||||
return {"available": available, "devices": devices, "wifi_country": wifi_country()}
|
||||
|
||||
|
||||
def wifi_country() -> str:
|
||||
if not shutil.which("iw"):
|
||||
return ""
|
||||
try:
|
||||
out = subprocess.run(["iw", "reg", "get"], capture_output=True, text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
match = re.search(r"country ([A-Z0-9]{2}):", out)
|
||||
return match.group(1) if match and match.group(1) != "00" else ""
|
||||
|
||||
|
||||
def pending_change(pending_file: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Filesystem locations used by every Daynivo 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 update_state_file(self) -> Path:
|
||||
return self.state_dir / "update.json"
|
||||
|
||||
@property
|
||||
def audit_log(self) -> Path:
|
||||
return self.log_dir / "audit.log"
|
||||
|
||||
|
||||
def default_paths() -> Paths:
|
||||
return Paths()
|
||||
|
||||
|
||||
_ENV = {
|
||||
"config_dir": "VOXIP_CONFIG_DIR",
|
||||
"state_dir": "VOXIP_STATE_DIR",
|
||||
"network_state_dir": "VOXIP_NETWORK_STATE_DIR",
|
||||
"agent_run_dir": "VOXIP_AGENT_RUN_DIR",
|
||||
"browser_run_dir": "VOXIP_BROWSER_RUN_DIR",
|
||||
"health_run_dir": "VOXIP_HEALTH_RUN_DIR",
|
||||
"share_dir": "VOXIP_SHARE_DIR",
|
||||
"log_dir": "VOXIP_LOG_DIR",
|
||||
"helper": "VOXIP_HELPER",
|
||||
}
|
||||
|
||||
|
||||
def load_paths() -> Paths:
|
||||
overrides = {field: Path(os.environ[env]) for field, env in _ENV.items() if os.environ.get(env)}
|
||||
return Paths(**overrides)
|
||||
|
||||
|
||||
def read_version(paths: Paths) -> str:
|
||||
try:
|
||||
return paths.version_file.read_text(encoding="utf-8").strip() or "onbekend"
|
||||
except OSError:
|
||||
here = Path(__file__).resolve().parents[2] / "VERSION"
|
||||
try:
|
||||
return here.read_text(encoding="utf-8").strip() + "-dev"
|
||||
except OSError:
|
||||
return "dev"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Read-only access to systemd unit state and the journal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
BROWSER_UNIT = "voxip-kiosk-browser.service"
|
||||
X_UNIT = "voxip-kiosk-x.service"
|
||||
AGENT_UNIT = "voxip-kiosk-agent.service"
|
||||
MANAGER_UNIT = "voxip-kiosk-manager.service"
|
||||
HEALTH_UNIT = "voxip-kiosk-healthcheck.service"
|
||||
|
||||
# Units whose logs the web interface may show (label → journalctl -u args).
|
||||
LOG_SOURCES: dict[str, tuple[str, list[str]]] = {
|
||||
"kiosk": ("Kiosk (Chromium, X, agent)", [BROWSER_UNIT, X_UNIT, AGENT_UNIT]),
|
||||
"browser": ("Chromium", [BROWSER_UNIT]),
|
||||
"agent": ("Kiosk-agent", [AGENT_UNIT]),
|
||||
"health": ("Healthcheck", [HEALTH_UNIT]),
|
||||
"manager": ("Beheerinterface", [MANAGER_UNIT]),
|
||||
"network": ("Netwerk", ["NetworkManager.service", "voxip-network-guard.service", "voxip-network-rollback.service", "voxip-network-activate.service"]),
|
||||
"system": ("Volledig systeem", []),
|
||||
}
|
||||
LOG_LINES = (50, 100, 200, 500)
|
||||
|
||||
_SHOW_PROPS = "ActiveState,SubState,Result,NRestarts,ActiveEnterTimestamp,ExecMainStatus,LoadState"
|
||||
|
||||
|
||||
def unit_status(unit: str) -> dict[str, str]:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["systemctl", "show", unit, "--no-pager", "-p", _SHOW_PROPS],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {}
|
||||
return parse_show(out)
|
||||
|
||||
|
||||
def parse_show(output: str) -> dict[str, str]:
|
||||
props = {}
|
||||
for line in output.splitlines():
|
||||
key, sep, value = line.partition("=")
|
||||
if sep:
|
||||
props[key] = value
|
||||
return props
|
||||
|
||||
|
||||
def describe(props: dict[str, str]) -> tuple[str, str]:
|
||||
"""(status, Dutch label) for a unit: status ∈ ok / starting / stopped / error / unknown."""
|
||||
active = props.get("ActiveState")
|
||||
if not active:
|
||||
return "unknown", "Onbekend"
|
||||
if active == "active":
|
||||
return "ok", "Actief"
|
||||
if active in ("activating", "reloading"):
|
||||
return "starting", "Bezig met starten"
|
||||
if active == "failed":
|
||||
return "error", "Fout"
|
||||
if active == "deactivating":
|
||||
return "starting", "Bezig met stoppen"
|
||||
if props.get("Result", "success") not in ("success", ""):
|
||||
return "error", "Gestopt na fout"
|
||||
return "stopped", "Gestopt"
|
||||
|
||||
|
||||
def journal(source: str, lines: int = 100, priority: str | None = None) -> str:
|
||||
if source not in LOG_SOURCES:
|
||||
raise ValueError("onbekende logbron")
|
||||
lines = lines if lines in LOG_LINES else 100
|
||||
cmd = ["journalctl", "--no-pager", "-o", "short-iso", "-n", str(lines)]
|
||||
for unit in LOG_SOURCES[source][1]:
|
||||
cmd += ["-u", unit]
|
||||
if priority in ("err", "warning"):
|
||||
cmd += ["-p", priority]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return f"Logs konden niet worden gelezen: {exc}"
|
||||
return proc.stdout or proc.stderr or "(geen regels)"
|
||||
|
||||
|
||||
def recent_errors(lines: int = 20) -> list[str]:
|
||||
"""Warnings and errors logged by the kiosk agent, browser launcher and healthcheck."""
|
||||
cmd = ["journalctl", "--no-pager", "-o", "short-iso", "-n", str(lines), "-p", "warning",
|
||||
"-u", AGENT_UNIT, "-u", BROWSER_UNIT, "-u", HEALTH_UNIT, "-u", X_UNIT]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
return [line for line in out.splitlines() if line and not line.startswith("-- ")]
|
||||
|
||||
|
||||
def summary() -> dict[str, Any]:
|
||||
result = {}
|
||||
for key, unit in (("browser", BROWSER_UNIT), ("x", X_UNIT), ("agent", AGENT_UNIT), ("manager", MANAGER_UNIT)):
|
||||
props = unit_status(unit)
|
||||
status, label = describe(props)
|
||||
result[key] = {"unit": unit, "status": status, "label": label, "restarts": props.get("NRestarts", "0"), "since": props.get("ActiveEnterTimestamp", "")}
|
||||
return result
|
||||
@@ -0,0 +1,42 @@
|
||||
"""One-time setup code that guards the initial admin password."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I confusion on screen
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
raw = "".join(secrets.choice(ALPHABET) for _ in range(8))
|
||||
return f"{raw[:4]}-{raw[4:]}"
|
||||
|
||||
|
||||
def normalise(code: str) -> str:
|
||||
cleaned = "".join(ch for ch in (code or "").upper() if ch.isalnum())
|
||||
return f"{cleaned[:4]}-{cleaned[4:]}" if len(cleaned) == 8 else cleaned
|
||||
|
||||
|
||||
def write(path: Path, code: str) -> None:
|
||||
"""Store the code readable for group kiosk (the setup screen) only."""
|
||||
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(code + "\n")
|
||||
os.chmod(path, 0o640)
|
||||
|
||||
|
||||
def read(path: Path) -> str | None:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def matches(path: Path, candidate: str) -> bool:
|
||||
stored = read(path)
|
||||
if not stored:
|
||||
return False
|
||||
return hmac.compare_digest(stored.encode(), normalise(candidate).encode())
|
||||
@@ -0,0 +1,201 @@
|
||||
"""System metrics for the dashboard, read without any privileges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_cpu_lock = threading.Lock()
|
||||
_cpu_prev: tuple[int, int] | None = None
|
||||
|
||||
|
||||
def _read(path: str) -> str | None:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def uptime_seconds(proc: str = "/proc") -> float | None:
|
||||
raw = _read(f"{proc}/uptime")
|
||||
try:
|
||||
return float(raw.split()[0]) if raw else None
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def load_average() -> tuple[float, float, float] | None:
|
||||
try:
|
||||
return os.getloadavg()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _cpu_times(proc: str) -> tuple[int, int] | None:
|
||||
raw = _read(f"{proc}/stat")
|
||||
if not raw:
|
||||
return None
|
||||
fields = raw.splitlines()[0].split()
|
||||
if not fields or fields[0] != "cpu":
|
||||
return None
|
||||
values = [int(x) for x in fields[1:]]
|
||||
idle = values[3] + (values[4] if len(values) > 4 else 0)
|
||||
return sum(values), idle
|
||||
|
||||
|
||||
def cpu_percent(proc: str = "/proc") -> float | None:
|
||||
"""CPU usage since the previous call (first call: since boot)."""
|
||||
global _cpu_prev
|
||||
now = _cpu_times(proc)
|
||||
if now is None:
|
||||
return None
|
||||
with _cpu_lock:
|
||||
prev, _cpu_prev = _cpu_prev, now
|
||||
total, idle = now
|
||||
if prev is not None and total > prev[0]:
|
||||
total, idle = total - prev[0], idle - prev[1]
|
||||
if total <= 0:
|
||||
return None
|
||||
return round(100.0 * (1 - idle / total), 1)
|
||||
|
||||
|
||||
def temperature_c(sys: str = "/sys") -> float | None:
|
||||
raw = _read(f"{sys}/class/thermal/thermal_zone0/temp")
|
||||
try:
|
||||
return round(int(raw.strip()) / 1000, 1) if raw else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def memory(proc: str = "/proc") -> dict[str, Any] | None:
|
||||
raw = _read(f"{proc}/meminfo")
|
||||
if not raw:
|
||||
return None
|
||||
info = {}
|
||||
for line in raw.splitlines():
|
||||
key, _, rest = line.partition(":")
|
||||
try:
|
||||
info[key] = int(rest.split()[0]) * 1024
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
total = info.get("MemTotal")
|
||||
avail = info.get("MemAvailable")
|
||||
if not total or avail is None:
|
||||
return None
|
||||
used = total - avail
|
||||
return {"total": total, "used": used, "percent": round(100 * used / total, 1)}
|
||||
|
||||
|
||||
def disk(path: str = "/") -> dict[str, Any] | None:
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
except OSError:
|
||||
return None
|
||||
return {"total": usage.total, "used": usage.used, "percent": round(100 * usage.used / usage.total, 1)}
|
||||
|
||||
|
||||
THROTTLE_FLAGS = {
|
||||
0: "Onderspanning",
|
||||
1: "Frequentie begrensd",
|
||||
2: "Afgeknepen (throttled)",
|
||||
3: "Temperatuurlimiet",
|
||||
16: "Onderspanning opgetreden",
|
||||
17: "Frequentiebegrenzing opgetreden",
|
||||
18: "Afknijpen opgetreden",
|
||||
19: "Temperatuurlimiet opgetreden",
|
||||
}
|
||||
|
||||
|
||||
def parse_throttled(output: str) -> dict[str, Any] | None:
|
||||
_, _, value = output.strip().partition("=")
|
||||
try:
|
||||
bits = int(value, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
flags = [label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << bit)]
|
||||
return {"raw": hex(bits), "now": bool(bits & 0xF), "ever": bool(bits & 0xF0000), "flags": flags}
|
||||
|
||||
|
||||
def throttled() -> dict[str, Any] | None:
|
||||
"""Power/thermal throttling as reported by the firmware (needs group video)."""
|
||||
if not shutil.which("vcgencmd"):
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True, text=True, timeout=3, check=True).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return parse_throttled(out)
|
||||
|
||||
|
||||
def ip_addresses() -> dict[str, list[str]]:
|
||||
"""Global addresses per interface, e.g. ``{"eth0": ["192.168.1.20/24"]}``."""
|
||||
try:
|
||||
out = subprocess.run(["ip", "-j", "addr", "show"], capture_output=True, text=True, timeout=3, check=True).stdout
|
||||
data = json.loads(out)
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return {}
|
||||
return parse_ip_json(data)
|
||||
|
||||
|
||||
def parse_ip_json(data: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {}
|
||||
for iface in data:
|
||||
name = iface.get("ifname", "")
|
||||
if name == "lo":
|
||||
continue
|
||||
addrs = [
|
||||
f"{a['local']}/{a['prefixlen']}"
|
||||
for a in iface.get("addr_info", [])
|
||||
if a.get("scope") == "global" and "local" in a
|
||||
]
|
||||
if addrs:
|
||||
result[name] = addrs
|
||||
return result
|
||||
|
||||
|
||||
def os_release() -> str:
|
||||
raw = _read("/etc/os-release") or ""
|
||||
for line in raw.splitlines():
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
return line.split("=", 1)[1].strip().strip('"')
|
||||
return "onbekend"
|
||||
|
||||
|
||||
def model() -> str:
|
||||
raw = _read("/proc/device-tree/model")
|
||||
return raw.strip("\x00\n ") if raw else "onbekend"
|
||||
|
||||
|
||||
def format_duration(seconds: float | None) -> str:
|
||||
if seconds is None:
|
||||
return "–"
|
||||
seconds = int(seconds)
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes = rem // 60
|
||||
if days:
|
||||
return f"{days} d {hours} u {minutes} min"
|
||||
if hours:
|
||||
return f"{hours} u {minutes} min"
|
||||
return f"{minutes} min"
|
||||
|
||||
|
||||
def format_bytes(value: int | None) -> str:
|
||||
if value is None:
|
||||
return "–"
|
||||
size = float(value)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.0f} {unit}" if unit in ("B", "KB") else f"{size:.1f} {unit}".replace(".", ",")
|
||||
size /= 1024
|
||||
return f"{value} B"
|
||||
|
||||
|
||||
def timestamp() -> float:
|
||||
return time.time()
|
||||
@@ -0,0 +1,244 @@
|
||||
"""voxip-kiosk-updater — installs new Daynivo Kiosk OS releases (runs as root).
|
||||
|
||||
A release is a tag ``vX.Y.Z`` on the Gitea repository with two assets:
|
||||
``daynivo-kiosk-os-X.Y.Z.tar.gz`` and its ``.sha256``. The updater
|
||||
|
||||
* checks the latest release and records the result in ``update.json`` in the
|
||||
state directory (the web interface shows it),
|
||||
* downloads and verifies a newer release, unpacks it under
|
||||
``/opt/daynivo-kiosk-os/<version>`` and runs that release's
|
||||
``update.sh --no-pull`` (config backup + ``install.sh --upgrade``).
|
||||
|
||||
It never installs an older or equal version. Usage::
|
||||
|
||||
voxip-kiosk-updater --check # only check
|
||||
voxip-kiosk-updater --auto # check; install when auto_update is on (daily timer)
|
||||
voxip-kiosk-updater --install # check and install when newer (button in the web interface)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Callable
|
||||
|
||||
from . import logutil
|
||||
from .config import atomic_write_json, load_config
|
||||
from .paths import Paths, default_paths, read_version
|
||||
|
||||
log = logging.getLogger("voxip-kiosk-updater")
|
||||
|
||||
RELEASE_API = "https://git.rothman.nl/api/v1/repos/rothman/daynivo-kiosk-os/releases/latest"
|
||||
ASSET_PREFIX = "daynivo-kiosk-os-"
|
||||
RELEASE_DIR = Path("/opt/daynivo-kiosk-os")
|
||||
CACHE_DIR = Path("/var/cache/voxip-kiosk")
|
||||
KEEP_RELEASES = 2
|
||||
MAX_DOWNLOAD = 64 * 1024 * 1024
|
||||
USER_AGENT = "Daynivo-Kiosk-OS/updater"
|
||||
_VERSION = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Release:
|
||||
version: str
|
||||
tarball_url: str
|
||||
checksum_url: str
|
||||
page_url: str
|
||||
|
||||
|
||||
def parse_version(text: str) -> tuple[int, int, int] | None:
|
||||
match = _VERSION.match(text.strip())
|
||||
return (int(match[1]), int(match[2]), int(match[3])) if match else None
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str) -> bool:
|
||||
new, old = parse_version(candidate), parse_version(current.removesuffix("-dev"))
|
||||
return new is not None and (old is None or new > old)
|
||||
|
||||
|
||||
def http_get(url: str, limit: int = MAX_DOWNLOAD, timeout: float = 30) -> bytes:
|
||||
if not url.startswith("https://"):
|
||||
raise UpdateError(f"Alleen HTTPS-downloads: {url}")
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json, */*"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response:
|
||||
data = response.read(limit + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404 and url == RELEASE_API:
|
||||
raise UpdateError("Er is nog geen release gepubliceerd") from exc
|
||||
raise UpdateError(f"Downloaden mislukt ({url}): HTTP {exc.code}") from exc
|
||||
except OSError as exc:
|
||||
raise UpdateError(f"Downloaden mislukt ({url}): {exc}") from exc
|
||||
if len(data) > limit:
|
||||
raise UpdateError(f"Download te groot: {url}")
|
||||
return data
|
||||
|
||||
|
||||
def parse_release(data: dict[str, Any]) -> Release:
|
||||
version = str(data.get("tag_name", "")).removeprefix("v")
|
||||
if parse_version(version) is None:
|
||||
raise UpdateError(f"Onbekend versienummer in release: {data.get('tag_name')!r}")
|
||||
assets = {a.get("name"): a.get("browser_download_url") for a in data.get("assets") or [] if isinstance(a, dict)}
|
||||
tarball = assets.get(f"{ASSET_PREFIX}{version}.tar.gz")
|
||||
checksum = assets.get(f"{ASSET_PREFIX}{version}.tar.gz.sha256")
|
||||
if not tarball or not checksum:
|
||||
raise UpdateError(f"Release {version} mist het installatiebestand of de checksum")
|
||||
return Release(version, tarball, checksum, str(data.get("html_url") or ""))
|
||||
|
||||
|
||||
def fetch_latest(get: Callable[[str], bytes] = http_get, api: str = RELEASE_API) -> Release:
|
||||
try:
|
||||
data = json.loads(get(api))
|
||||
except ValueError as exc:
|
||||
raise UpdateError("Ongeldig antwoord van de releaseserver") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise UpdateError("Ongeldig antwoord van de releaseserver")
|
||||
return parse_release(data)
|
||||
|
||||
|
||||
def read_state(paths: Paths) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(paths.update_state_file.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def safe_extract(archive: Path, target: Path) -> None:
|
||||
"""Unpack only regular files and directories that stay inside ``target``."""
|
||||
with tarfile.open(archive, "r:gz") as tar:
|
||||
members = tar.getmembers()
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not (member.isfile() or member.isdir()):
|
||||
raise UpdateError(f"Onveilig bestand in release: {member.name}")
|
||||
tar.extractall(target, members=members) # noqa: S202 - members checked above
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self, paths: Paths | None = None, get: Callable[[str], bytes] = http_get,
|
||||
run: Callable[..., Any] = subprocess.run, release_dir: Path = RELEASE_DIR,
|
||||
cache_dir: Path = CACHE_DIR, now: Callable[[], float] = time.time):
|
||||
self.paths = paths or default_paths()
|
||||
self.get = get
|
||||
self.run = run
|
||||
self.release_dir = release_dir
|
||||
self.cache_dir = cache_dir
|
||||
self.now = now
|
||||
|
||||
def _write_state(self, **values: Any) -> dict[str, Any]:
|
||||
state = {**read_state(self.paths), **values}
|
||||
try:
|
||||
atomic_write_json(self.paths.update_state_file, state, mode=0o644, owner=(0, 0))
|
||||
except PermissionError:
|
||||
atomic_write_json(self.paths.update_state_file, state, mode=0o644)
|
||||
except OSError as exc:
|
||||
log.warning("Kon updatestatus niet opslaan: %s", exc)
|
||||
return state
|
||||
|
||||
def check(self) -> tuple[dict[str, Any], Release | None]:
|
||||
current = read_version(self.paths)
|
||||
try:
|
||||
release = fetch_latest(self.get)
|
||||
except UpdateError as exc:
|
||||
log.warning("Controleren op updates mislukt: %s", exc)
|
||||
return self._write_state(checked_at=int(self.now()), current=current, check_error=str(exc)), None
|
||||
available = is_newer(release.version, current)
|
||||
state = self._write_state(checked_at=int(self.now()), current=current, latest=release.version,
|
||||
available=available, release_url=release.page_url, check_error=None)
|
||||
if available:
|
||||
log.info("Nieuwe versie beschikbaar: %s (geïnstalleerd: %s)", release.version, current)
|
||||
return state, release if available else None
|
||||
|
||||
def install(self, release: Release) -> dict[str, Any]:
|
||||
current = read_version(self.paths)
|
||||
if not is_newer(release.version, current):
|
||||
raise UpdateError(f"Versie {release.version} is niet nieuwer dan {current}")
|
||||
self._write_state(status="updating", target=release.version, started_at=int(self.now()), install_error=None)
|
||||
log.info("Bijwerken naar %s", release.version)
|
||||
try:
|
||||
folder = self._download(release)
|
||||
proc = self.run([str(folder / "update.sh"), "--no-pull"], capture_output=True, text=True, timeout=3600)
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-5:]
|
||||
raise UpdateError("Installatie mislukt: " + " / ".join(tail))
|
||||
except (UpdateError, OSError, subprocess.SubprocessError) as exc:
|
||||
log.error("Bijwerken naar %s mislukt: %s", release.version, exc)
|
||||
return self._write_state(status="failed", finished_at=int(self.now()), install_error=str(exc))
|
||||
self._prune(keep=release.version)
|
||||
log.info("Bijgewerkt naar %s", release.version)
|
||||
return self._write_state(status="done", finished_at=int(self.now()), current=release.version,
|
||||
available=False, install_error=None)
|
||||
|
||||
def _download(self, release: Release) -> Path:
|
||||
tarball = self.get(release.tarball_url)
|
||||
expected = self.get(release.checksum_url, limit=4096).decode("ascii", "replace").split()
|
||||
actual = hashlib.sha256(tarball).hexdigest()
|
||||
if not expected or expected[0].lower() != actual:
|
||||
raise UpdateError("Checksum klopt niet; de download wordt niet geïnstalleerd")
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.release_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=self.cache_dir) as tmp:
|
||||
archive = Path(tmp) / "release.tar.gz"
|
||||
archive.write_bytes(tarball)
|
||||
unpacked = Path(tmp) / "unpacked"
|
||||
safe_extract(archive, unpacked)
|
||||
source = unpacked / f"{ASSET_PREFIX}{release.version}"
|
||||
found = (source / "VERSION").read_text(encoding="utf-8").strip() if (source / "VERSION").exists() else None
|
||||
if found != release.version:
|
||||
raise UpdateError(f"Release bevat versie {found!r} in plaats van {release.version}")
|
||||
target = self.release_dir / release.version
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
shutil.move(str(source), target)
|
||||
return target
|
||||
|
||||
def _prune(self, keep: str) -> None:
|
||||
versions = sorted((p for p in self.release_dir.iterdir() if p.is_dir() and parse_version(p.name)),
|
||||
key=lambda p: parse_version(p.name), reverse=True)
|
||||
for old in versions[KEEP_RELEASES:]:
|
||||
if old.name != keep:
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, updater: Updater | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="voxip-kiosk-updater", description="Daynivo Kiosk OS bijwerken.")
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--check", action="store_true", help="alleen controleren")
|
||||
mode.add_argument("--auto", action="store_true", help="controleren en bijwerken als automatisch bijwerken aan staat")
|
||||
mode.add_argument("--install", action="store_true", help="controleren en een nieuwere versie installeren")
|
||||
args = parser.parse_args(argv)
|
||||
logutil.setup()
|
||||
updater = updater or Updater()
|
||||
|
||||
state, release = updater.check()
|
||||
if args.check or release is None:
|
||||
print(json.dumps(state, ensure_ascii=False))
|
||||
return 1 if state.get("check_error") else 0
|
||||
if args.auto:
|
||||
if not load_config(updater.paths.config_file)["auto_update"]:
|
||||
log.info("Automatisch bijwerken staat uit; %s wordt niet geïnstalleerd", release.version)
|
||||
return 0
|
||||
if (updater.paths.health_run_dir / "maintenance").exists():
|
||||
log.info("Onderhoudsmodus actief; bijwerken overgeslagen")
|
||||
return 0
|
||||
state = updater.install(release)
|
||||
print(json.dumps(state, ensure_ascii=False))
|
||||
return 0 if state.get("status") == "done" else 1
|
||||
@@ -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 (Daynivo display URLs carry
|
||||
# their access token as ?token=...). They are masked wherever a URL is shown
|
||||
# or logged.
|
||||
SECRET_PARAMS = {"token", "key", "apikey", "api_key", "secret", "password", "auth", "access_token"}
|
||||
|
||||
|
||||
def kiosk_url(value: str) -> str:
|
||||
"""A kiosk URL: HTTPS only, with a host, without embedded credentials."""
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError("Ongeldige URL.")
|
||||
url = value.strip()
|
||||
if not url:
|
||||
raise ValidationError("Vul een URL in.")
|
||||
if len(url) > 2048:
|
||||
raise ValidationError("De URL is te lang (maximaal 2048 tekens).")
|
||||
if _CONTROL.search(url) or " " in url:
|
||||
raise ValidationError("De URL mag geen spaties of stuurtekens bevatten.")
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
raise ValidationError("De URL is ongeldig.") from None
|
||||
if parts.scheme.lower() != "https":
|
||||
raise ValidationError("Alleen HTTPS-URL's zijn toegestaan (https://…).")
|
||||
if not parts.hostname:
|
||||
raise ValidationError("De URL bevat geen hostnaam.")
|
||||
if parts.username or parts.password:
|
||||
raise ValidationError("Gebruikersnaam/wachtwoord in de URL is niet toegestaan.")
|
||||
if port is not None and not 0 < port < 65536:
|
||||
raise ValidationError("Ongeldige poort in de URL.")
|
||||
host = parts.hostname
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
if not all(_HOSTNAME.match(label) for label in host.lower().rstrip(".").split(".")):
|
||||
raise ValidationError("De hostnaam in de URL is ongeldig.") from None
|
||||
return url
|
||||
|
||||
|
||||
def mask_url(url: str) -> str:
|
||||
"""Hide secret query parameter values: ``?token=abcdef1234`` → ``?token=…1234``."""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return url
|
||||
if not parts.query:
|
||||
return url
|
||||
pairs = []
|
||||
for pair in parts.query.split("&"):
|
||||
key, sep, val = pair.partition("=")
|
||||
if sep and val and unquote_plus(key).lower() in SECRET_PARAMS:
|
||||
val = "…" + val[-4:] if len(val) > 8 else "…"
|
||||
pairs.append(key + sep + val)
|
||||
return urlunsplit(parts._replace(query="&".join(pairs)))
|
||||
|
||||
|
||||
def hostname(value: str) -> str:
|
||||
name = (value or "").strip().lower()
|
||||
if not _HOSTNAME.match(name):
|
||||
raise ValidationError(
|
||||
"Een hostname bestaat uit 1–63 tekens: kleine letters, cijfers en "
|
||||
"koppeltekens, niet beginnend of eindigend met een koppelteken."
|
||||
)
|
||||
if name == "localhost":
|
||||
raise ValidationError("'localhost' kan niet als hostname worden gebruikt.")
|
||||
return name
|
||||
|
||||
|
||||
def device_name(value: str) -> str:
|
||||
name = (value or "").strip()
|
||||
if not name:
|
||||
raise ValidationError("Vul een apparaatnaam in.")
|
||||
if len(name) > 64 or _CONTROL.search(name):
|
||||
raise ValidationError("De apparaatnaam mag maximaal 64 tekens bevatten, zonder stuurtekens.")
|
||||
return name
|
||||
|
||||
|
||||
def resolution(value: str) -> str:
|
||||
val = (value or "").strip().lower()
|
||||
if val == "auto":
|
||||
return val
|
||||
match = _RESOLUTION.match(val)
|
||||
if not match:
|
||||
raise ValidationError("Gebruik 'auto' of een resolutie als 1920x1080 of 1920x1080@60.")
|
||||
width, height = int(match.group(1)), int(match.group(2))
|
||||
if not (320 <= width <= 4096 and 200 <= height <= 4096):
|
||||
raise ValidationError("Resolutie buiten het ondersteunde bereik.")
|
||||
return val
|
||||
|
||||
|
||||
def parse_resolution(value: str) -> tuple[str, str | None] | None:
|
||||
"""``"1920x1080@60"`` → ``("1920x1080", "60")``; ``"auto"`` → ``None``."""
|
||||
if value == "auto":
|
||||
return None
|
||||
match = _RESOLUTION.match(value)
|
||||
if not match:
|
||||
return None
|
||||
return f"{match.group(1)}x{match.group(2)}", match.group(3)
|
||||
|
||||
|
||||
def ipv4_address(value: str) -> str:
|
||||
try:
|
||||
addr = ipaddress.IPv4Address((value or "").strip())
|
||||
except ValueError:
|
||||
raise ValidationError(f"'{value}' is geen geldig IPv4-adres.") from None
|
||||
if addr.is_multicast or addr.is_unspecified or addr.is_loopback:
|
||||
raise ValidationError(f"'{value}' kan hier niet gebruikt worden.")
|
||||
return str(addr)
|
||||
|
||||
|
||||
def ipv4_interface(value: str) -> str:
|
||||
"""``192.168.1.50/24`` (a host address with prefix length)."""
|
||||
raw = (value or "").strip()
|
||||
if "/" not in raw:
|
||||
raise ValidationError("Vul het IP-adres in met prefixlengte, bijvoorbeeld 192.168.1.50/24.")
|
||||
try:
|
||||
iface = ipaddress.IPv4Interface(raw)
|
||||
except ValueError:
|
||||
raise ValidationError(f"'{raw}' is geen geldig IPv4-adres met prefix.") from None
|
||||
if not 8 <= iface.network.prefixlen <= 32:
|
||||
raise ValidationError("De prefixlengte moet tussen 8 en 32 liggen.")
|
||||
if iface.network.prefixlen < 31 and iface.ip in (iface.network.network_address, iface.network.broadcast_address):
|
||||
raise ValidationError("Het adres is een netwerk- of broadcastadres.")
|
||||
ipv4_address(str(iface.ip))
|
||||
return iface.with_prefixlen
|
||||
|
||||
|
||||
def dns_servers(values: list[str] | str) -> list[str]:
|
||||
if isinstance(values, str):
|
||||
values = re.split(r"[\s,;]+", values)
|
||||
result: list[str] = []
|
||||
for raw in values:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
addr = ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
raise ValidationError(f"'{raw}' is geen geldig DNS-serveradres.") from None
|
||||
if str(addr) not in result:
|
||||
result.append(str(addr))
|
||||
if len(result) > 3:
|
||||
raise ValidationError("Maximaal drie DNS-servers.")
|
||||
return result
|
||||
|
||||
|
||||
def ssid(value: str) -> str:
|
||||
val = value or ""
|
||||
if not val:
|
||||
raise ValidationError("Vul een netwerknaam (SSID) in.")
|
||||
if len(val.encode("utf-8")) > 32:
|
||||
raise ValidationError("Een SSID is maximaal 32 bytes.")
|
||||
if _CONTROL.search(val):
|
||||
raise ValidationError("De SSID bevat ongeldige tekens.")
|
||||
return val
|
||||
|
||||
|
||||
def wifi_psk(value: str) -> str:
|
||||
val = value or ""
|
||||
if _HEX64.match(val):
|
||||
return val
|
||||
if not 8 <= len(val) <= 63:
|
||||
raise ValidationError("Een wifi-wachtwoord is 8 tot 63 tekens (of 64 hexadecimale tekens).")
|
||||
if any(not (32 <= ord(ch) <= 126) for ch in val):
|
||||
raise ValidationError("Het wifi-wachtwoord mag alleen gewone ASCII-tekens bevatten.")
|
||||
return val
|
||||
|
||||
|
||||
def country_code(value: str) -> str:
|
||||
val = (value or "").strip().upper()
|
||||
if not _COUNTRY.match(val):
|
||||
raise ValidationError("Gebruik een landcode van twee letters, bijvoorbeeld NL.")
|
||||
return val
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Daynivo Kiosk Manager — Flask application factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from ..cdp import CDP
|
||||
from ..config import load_config
|
||||
from ..helper_client import HelperClient
|
||||
from ..paths import Paths, load_paths, read_version
|
||||
from .auth import CredentialStore, LoginThrottle
|
||||
|
||||
|
||||
@dataclass
|
||||
class Services:
|
||||
paths: Paths
|
||||
credentials: CredentialStore
|
||||
throttle: LoginThrottle
|
||||
helper: Any
|
||||
cdp: Any
|
||||
version: str
|
||||
|
||||
|
||||
def _secret_key(paths: Paths) -> bytes:
|
||||
path = paths.secret_key_file
|
||||
try:
|
||||
key = path.read_bytes()
|
||||
if len(key) >= 32:
|
||||
return key
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
key = secrets.token_bytes(64)
|
||||
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(key)
|
||||
return key
|
||||
|
||||
|
||||
def create_app(paths: Paths | None = None, *, helper: Any = None, cdp: Any = None) -> Flask:
|
||||
paths = paths or load_paths()
|
||||
config = load_config(paths.config_file)
|
||||
app = Flask(__name__)
|
||||
app.secret_key = _secret_key(paths)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_NAME="voxip_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Strict",
|
||||
SESSION_COOKIE_SECURE=config["admin_https"],
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
|
||||
MAX_CONTENT_LENGTH=64 * 1024,
|
||||
TEMPLATES_AUTO_RELOAD=False,
|
||||
)
|
||||
credentials = CredentialStore(paths.admin_file, paths.setup_code_file)
|
||||
credentials.ensure_setup_code()
|
||||
app.extensions["voxip"] = Services(
|
||||
paths=paths,
|
||||
credentials=credentials,
|
||||
throttle=LoginThrottle(),
|
||||
helper=helper or HelperClient(paths.helper),
|
||||
cdp=cdp or CDP(timeout=5),
|
||||
version=read_version(paths),
|
||||
)
|
||||
|
||||
from .views import bp
|
||||
|
||||
app.register_blueprint(bp)
|
||||
return app
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Admin credentials (Argon2id) and login throttling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from argon2 import PasswordHasher, Type
|
||||
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
|
||||
|
||||
from .. import setupcode
|
||||
from ..config import atomic_write_json
|
||||
|
||||
# 64 MiB / 3 passes / 2 lanes: ~0.5 s on a Raspberry Pi 3, fine for a single admin.
|
||||
HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16, type=Type.ID)
|
||||
|
||||
MIN_LENGTH = 10
|
||||
MAX_LENGTH = 128
|
||||
WEAK = {"admin", "administrator", "password", "wachtwoord", "raspberry", "voxip", "kiosk", "welkom", "welcome", "qwerty", "123456"}
|
||||
|
||||
|
||||
def password_problem(password: str, confirm: str | None = None) -> str | None:
|
||||
if confirm is not None and password != confirm:
|
||||
return "De wachtwoorden komen niet overeen."
|
||||
if len(password) < MIN_LENGTH:
|
||||
return f"Gebruik minimaal {MIN_LENGTH} tekens."
|
||||
if len(password) > MAX_LENGTH:
|
||||
return f"Gebruik maximaal {MAX_LENGTH} tekens."
|
||||
remainder = password.lower()
|
||||
for word in WEAK:
|
||||
remainder = remainder.replace(word, "")
|
||||
if len(set(password)) < 4 or len(remainder) < 6 or remainder.isdigit():
|
||||
return "Dit wachtwoord is te eenvoudig. Kies een uniek wachtwoord."
|
||||
return None
|
||||
|
||||
|
||||
class CredentialStore:
|
||||
def __init__(self, admin_file: Path, setup_code_file: Path):
|
||||
self.admin_file = admin_file
|
||||
self.setup_code_file = setup_code_file
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _load(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(self.admin_file.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict) or not str(data.get("password_hash", "")).startswith("$argon2id$"):
|
||||
return None
|
||||
return data
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return self._load() is not None
|
||||
|
||||
def ensure_setup_code(self) -> str | None:
|
||||
"""Create a setup code while no password exists; remove it afterwards."""
|
||||
if self.is_configured():
|
||||
self.setup_code_file.unlink(missing_ok=True)
|
||||
return None
|
||||
code = setupcode.read(self.setup_code_file)
|
||||
if not code:
|
||||
code = setupcode.generate()
|
||||
setupcode.write(self.setup_code_file, code)
|
||||
return code
|
||||
|
||||
def check_setup_code(self, candidate: str) -> bool:
|
||||
return setupcode.matches(self.setup_code_file, candidate)
|
||||
|
||||
def set_password(self, password: str) -> str:
|
||||
"""Store a new password hash; returns the new session epoch (old sessions become invalid)."""
|
||||
epoch = secrets.token_hex(16)
|
||||
record = {
|
||||
"password_hash": HASHER.hash(password),
|
||||
"session_epoch": epoch,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
with self._lock:
|
||||
atomic_write_json(self.admin_file, record, mode=0o600)
|
||||
self.setup_code_file.unlink(missing_ok=True)
|
||||
return epoch
|
||||
|
||||
def verify(self, password: str) -> bool:
|
||||
data = self._load()
|
||||
if data is None or len(password) > MAX_LENGTH:
|
||||
return False
|
||||
try:
|
||||
HASHER.verify(data["password_hash"], password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHash):
|
||||
return False
|
||||
if HASHER.check_needs_rehash(data["password_hash"]):
|
||||
data["password_hash"] = HASHER.hash(password)
|
||||
with self._lock:
|
||||
atomic_write_json(self.admin_file, data, mode=0o600)
|
||||
return True
|
||||
|
||||
def session_epoch(self) -> str | None:
|
||||
data = self._load()
|
||||
return data.get("session_epoch") if data else None
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Per-IP exponential lockout plus a global brake against distributed guessing."""
|
||||
|
||||
def __init__(self, free_attempts: int = 5, window: float = 900, max_lock: float = 900,
|
||||
global_limit: int = 30, global_window: float = 300, clock=time.monotonic):
|
||||
self.free_attempts = free_attempts
|
||||
self.window = window
|
||||
self.max_lock = max_lock
|
||||
self.global_limit = global_limit
|
||||
self.global_window = global_window
|
||||
self.clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, deque] = {}
|
||||
self._locked_until: dict[str, float] = {}
|
||||
self._global: deque = deque()
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
while self._global and now - self._global[0] > self.global_window:
|
||||
self._global.popleft()
|
||||
for ip in list(self._failures):
|
||||
q = self._failures[ip]
|
||||
while q and now - q[0] > self.window:
|
||||
q.popleft()
|
||||
if not q and self._locked_until.get(ip, 0) <= now:
|
||||
self._failures.pop(ip, None)
|
||||
self._locked_until.pop(ip, None)
|
||||
|
||||
def retry_after(self, ip: str) -> int:
|
||||
with self._lock:
|
||||
now = self.clock()
|
||||
self._prune(now)
|
||||
wait = self._locked_until.get(ip, 0) - now
|
||||
if len(self._global) >= self.global_limit:
|
||||
wait = max(wait, self.global_window - (now - self._global[0]))
|
||||
return max(0, int(wait + 0.999))
|
||||
|
||||
def failure(self, ip: str) -> None:
|
||||
with self._lock:
|
||||
now = self.clock()
|
||||
self._prune(now)
|
||||
q = self._failures.setdefault(ip, deque())
|
||||
q.append(now)
|
||||
self._global.append(now)
|
||||
excess = len(q) - self.free_attempts
|
||||
if excess >= 0:
|
||||
self._locked_until[ip] = now + min(self.max_lock, 30 * (2 ** excess))
|
||||
|
||||
def success(self, ip: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(ip, None)
|
||||
self._locked_until.pop(ip, None)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Request-level protections: client network filter, CSRF, security headers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from flask import Request, session
|
||||
|
||||
CSRF_FIELD = "csrf_token"
|
||||
CSRF_HEADER = "X-CSRF-Token"
|
||||
_VPN_RANGES = (ipaddress.ip_network("100.64.0.0/10"),) # CGNAT, used by Tailscale and many VPNs
|
||||
|
||||
CSP = "; ".join([
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data: blob:",
|
||||
"style-src 'self'",
|
||||
"script-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
])
|
||||
|
||||
|
||||
def client_allowed(remote_addr: str | None, allow_public: bool, local_networks: list[str] = ()) -> bool:
|
||||
"""Only local networks may reach the admin interface unless explicitly opened up.
|
||||
|
||||
Local means: private/link-local/loopback ranges, CGNAT (VPN) or one of
|
||||
the subnets this device itself is on (covers global IPv6 prefixes on
|
||||
the LAN).
|
||||
"""
|
||||
if allow_public:
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address((remote_addr or "").split("%", 1)[0])
|
||||
except ValueError:
|
||||
return False
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
|
||||
addr = addr.ipv4_mapped
|
||||
if addr.is_multicast:
|
||||
return False
|
||||
if addr.is_loopback or addr.is_link_local or addr.is_private:
|
||||
return True
|
||||
if any(addr in net for net in _VPN_RANGES if addr.version == net.version):
|
||||
return True
|
||||
for cidr in local_networks:
|
||||
try:
|
||||
net = ipaddress.ip_interface(cidr).network
|
||||
except ValueError:
|
||||
continue
|
||||
if net.version == addr.version and addr in net and net.prefixlen >= (16 if net.version == 4 else 48):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def csrf_token() -> str:
|
||||
token = session.get("csrf")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session["csrf"] = token
|
||||
return token
|
||||
|
||||
|
||||
def csrf_valid(request: Request) -> bool:
|
||||
expected = session.get("csrf")
|
||||
supplied = request.headers.get(CSRF_HEADER) or request.form.get(CSRF_FIELD, "")
|
||||
if not expected or not supplied or not hmac.compare_digest(expected, supplied):
|
||||
return False
|
||||
origin = request.headers.get("Origin")
|
||||
if origin and origin != "null":
|
||||
return urlsplit(origin).netloc == request.host
|
||||
return True
|
||||
|
||||
|
||||
def apply_headers(response, secure: bool):
|
||||
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||
if response.mimetype in ("text/html", "application/json") or response.mimetype.startswith("image/"):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if secure:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000"
|
||||
return response
|
||||
@@ -0,0 +1,170 @@
|
||||
:root {
|
||||
--navy: #0b2545;
|
||||
--navy-2: #13315c;
|
||||
--accent: #0b2545;
|
||||
--accent-dark: #13315c;
|
||||
--bg: #f2f5f9;
|
||||
--card: #ffffff;
|
||||
--text: #1b2433;
|
||||
--muted: #5c6b80;
|
||||
--border: #dde3eb;
|
||||
--ok: #1f9d55;
|
||||
--warning: #d98a00;
|
||||
--error: #d64545;
|
||||
--stopped: #8592a3;
|
||||
--starting: #2f80ed;
|
||||
--radius: 8px;
|
||||
--shadow: none;
|
||||
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; }
|
||||
.mt-sm { margin-top: 1rem; }
|
||||
.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; 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: 56px; flex-wrap: wrap; }
|
||||
.brand { display: flex; align-items: center; gap: .6rem; color: #fff; text-decoration: none; font-size: 1.1rem; margin-right: auto; }
|
||||
/* The Daynivo mark has navy parts that would vanish on the navy bar. */
|
||||
.brand-mark { display: inline-flex; align-items: center; background: #fff; border-radius: 6px; padding: 3px 5px; }
|
||||
.brand-mark img { display: block; height: 22px; width: auto; }
|
||||
.mainnav { display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
|
||||
.mainnav a, .mainnav button { color: #d6e2f3; text-decoration: none; padding: .45rem .7rem; border-radius: 6px; 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; }
|
||||
.nav-logout { margin: 0; }
|
||||
.nav-menu { position: relative; }
|
||||
.nav-menu summary { list-style: none; color: #d6e2f3; padding: .45rem .7rem; border-radius: 6px; font-size: .95rem; cursor: pointer; user-select: none; }
|
||||
.nav-menu summary::-webkit-details-marker { display: none; }
|
||||
.nav-menu summary:hover, .nav-menu[open] summary { background: var(--navy-2); color: #fff; }
|
||||
.nav-menu-list { position: absolute; right: 0; top: calc(100% + 6px); min-width: 230px; padding: .3rem 0; background: #fff; border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px rgba(11, 37, 69, .15); z-index: 20; }
|
||||
.nav-menu-list form { margin: 0; }
|
||||
.mainnav .nav-menu-list button { display: block; width: 100%; text-align: left; padding: .55rem .9rem; border-radius: 0; color: var(--text); }
|
||||
.mainnav .nav-menu-list button:hover { background: #f1f4f8; color: var(--text); }
|
||||
.mainnav .nav-menu-list button.danger { color: #b03030; }
|
||||
.nav-menu-list hr { border: 0; border-top: 1px solid var(--border); margin: .3rem 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, .nav-menu summary { padding: .75rem; text-align: left; width: 100%; }
|
||||
.nav-menu-list { position: static; min-width: 0; margin: .25rem 0 0 .75rem; background: none; border: 0; border-left: 1px solid rgba(255, 255, 255, .2); border-radius: 0; box-shadow: none; }
|
||||
.mainnav .nav-menu-list button { color: #d6e2f3; }
|
||||
.mainnav .nav-menu-list button:hover { background: var(--navy-2); color: #fff; }
|
||||
.mainnav .nav-menu-list button.danger { color: #ffb4b4; }
|
||||
.nav-menu-list hr { border-top-color: rgba(255, 255, 255, .2); }
|
||||
}
|
||||
|
||||
/* --- 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: #f4f6f9; border-radius: 6px; font-size: .9rem; }
|
||||
|
||||
/* --- status badges ------------------------------------------------------------- */
|
||||
.badge { display: inline-flex; align-items: center; gap: .45rem; padding: .1rem .55rem; border-radius: 4px; font-size: .875rem; font-weight: 500; background: #eef1f5; color: var(--text); }
|
||||
.badge .dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--stopped); flex: none; }
|
||||
.badge-ok .dot { background: var(--ok); }
|
||||
.badge-warning .dot { background: var(--warning); }
|
||||
.badge-error { color: #a32c2c; } .badge-error .dot { background: var(--error); }
|
||||
.badge-starting .dot { background: var(--starting); animation: pulse 1.2s infinite; }
|
||||
.badge-stopped, .badge-unknown { color: var(--muted); }
|
||||
@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: #7d8ba0; 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-size: .95rem; font-weight: 500; padding: .45rem .9rem; border-radius: 6px; border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background .15s; min-height: 38px; }
|
||||
.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(--text); border-color: #c5cfdb; } .btn-secondary:hover { background: #f1f4f8; }
|
||||
.btn-danger { background: #fff; color: #b03030; border-color: #e3b5b5; } .btn-danger:hover { background: #fcf1f1; }
|
||||
.btn-big { padding: .55rem 1.1rem; min-height: 42px; }
|
||||
.btn-block { width: 100%; }
|
||||
.actions-row { display: flex; flex-wrap: wrap; gap: .5rem; 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 #9fb3cc; outline-offset: 1px; border-color: var(--navy); }
|
||||
.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: .7rem .9rem; border-radius: 6px; margin-bottom: 1rem; border: 1px solid; }
|
||||
.alert-success { background: #e3f5ea; border-color: #b6e2c6; color: #145c33; }
|
||||
.alert-error { background: #fde6e6; border-color: #f4bcbc; color: #8f2424; }
|
||||
.alert-warning { background: #fff1d6; border-color: #f3d39b; color: #7a4e00; }
|
||||
.alert-info { background: #e3eefc; border-color: #bcd4f7; color: #1d4f9c; }
|
||||
|
||||
/* --- logs & screenshot ----------------------------------------------------------------- */
|
||||
.log { background: #0f1b2d; color: #d7e3f4; padding: 1rem; border-radius: 10px; font-size: .8rem; line-height: 1.45; overflow: auto; max-height: 65vh; white-space: pre-wrap; word-break: break-word; margin: 1rem 0 0; }
|
||||
.log-short { max-height: 280px; margin-top: 0; }
|
||||
.screenshot { background: #0f1b2d; border-radius: 10px; min-height: 160px; display: flex; align-items: center; justify-content: center; margin-bottom: .8rem; overflow: hidden; }
|
||||
.screenshot p { color: #b9c7da; padding: 1rem; text-align: center; margin: 0; }
|
||||
.screenshot img { width: 100%; height: auto; display: block; }
|
||||
|
||||
/* --- dialog ------------------------------------------------------------------------------ */
|
||||
.dialog { border: 0; border-radius: var(--radius); padding: 1.5rem; max-width: min(440px, calc(100vw - 2rem)); box-shadow: 0 20px 60px rgba(0, 0, 0, .3); }
|
||||
.dialog::backdrop { background: rgba(11, 37, 69, .55); }
|
||||
.dialog form { margin: 0; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.25rem; flex-wrap: wrap; }
|
||||
@@ -0,0 +1,217 @@
|
||||
/* Daynivo 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));
|
||||
});
|
||||
}
|
||||
|
||||
// Actions menu: close after choosing an item, on a click outside it or on Escape
|
||||
const menu = $("[data-nav-menu]");
|
||||
if (menu) {
|
||||
document.addEventListener("click", (event) => {
|
||||
if (menu.open && (!menu.contains(event.target) || event.target.closest(".nav-menu-list button"))) menu.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && menu.open) menu.open = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 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));
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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 menu_action(url, label, confirm=None, confirm_title=None, danger=False) -%}
|
||||
<form method="post" action="{{ url }}" {% 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"{% if danger %} class="danger"{% endif %}>{{ label }}</button>
|
||||
</form>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro csrf() -%}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{%- endmacro %}
|
||||
|
||||
{# Width is applied by app.js: the CSP does not allow inline style attributes. #}
|
||||
{% macro meter(percent, field) -%}
|
||||
<div class="meter" role="presentation"><span data-meter="{{ field }}" data-pct="{{ percent }}"></span></div>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% from "_macros.html" import menu_action with context %}
|
||||
<!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 %} · Daynivo Kiosk Manager</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png">
|
||||
<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') }}">
|
||||
<span class="brand-mark"><img src="{{ url_for('static', filename='logo-mark.png') }}" alt="" width="34" height="22"></span>
|
||||
<span><strong>Daynivo</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>
|
||||
<details class="nav-menu" data-nav-menu>
|
||||
<summary>Acties <span aria-hidden="true">▾</span></summary>
|
||||
<div class="nav-menu-list">
|
||||
{{ menu_action(url_for('ui.kiosk_reload'), "Pagina herladen") }}
|
||||
{{ menu_action(url_for('ui.kiosk_restart'), "Chromium herstarten", confirm="Chromium wordt afgesloten en opnieuw gestart. Het scherm is enkele seconden zwart.") }}
|
||||
<hr>
|
||||
{{ menu_action(url_for('ui.system_reboot'), "Raspberry Pi herstarten", confirm="De Raspberry Pi herstart. Het scherm en de beheerinterface zijn ongeveer een minuut niet beschikbaar.", danger=True) }}
|
||||
{{ menu_action(url_for('ui.system_poweroff'), "Veilig uitschakelen", 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", danger=True) }}
|
||||
</div>
|
||||
</details>
|
||||
<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>Daynivo 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,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import badge, 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>
|
||||
|
||||
|
||||
<div class="grid" data-live-status="{{ url_for('ui.api_status') }}">
|
||||
<section class="card">
|
||||
<h2>Kiosk</h2>
|
||||
<dl class="kv">
|
||||
<dt>Chromium</dt><dd>{{ badge(s.browser.status, s.browser.label, "browser") }}</dd>
|
||||
<dt>Kiosk-URL</dt><dd class="break" data-field="url">{{ s.url }}</dd>
|
||||
<dt>Verbinding</dt><dd>{{ badge(s.network.status, s.network.label, "network") }}</dd>
|
||||
<dt>Herstarts</dt><dd data-field="browser.restarts">{{ s.browser.restarts }}</dd>
|
||||
</dl>
|
||||
{% if s.last_error %}
|
||||
<p class="note">Laatste melding: <span data-field="last_error.message">{{ s.last_error.message }}</span></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Systeem</h2>
|
||||
<dl class="kv">
|
||||
<dt>Uptime</dt><dd data-field="uptime">{{ s.uptime }}</dd>
|
||||
<dt>CPU-belasting</dt><dd><span data-field="cpu">{{ s.cpu }}</span> <span class="muted">(load <span data-field="load">{{ s.load }}</span>)</span></dd>
|
||||
<dt>Temperatuur</dt><dd>{{ badge(s.temperature_status, s.temperature, "temperature") }}</dd>
|
||||
{% if s.power %}<dt>Voeding</dt><dd>{{ badge(s.power.status, s.power.label, "power") }}</dd>{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Geheugen & opslag</h2>
|
||||
<dl class="kv">
|
||||
<dt>RAM</dt><dd><span data-field="memory">{{ s.memory }}</span>{{ meter(s.memory_percent, "memory_percent") }}</dd>
|
||||
<dt>SD-kaart</dt><dd><span data-field="disk">{{ s.disk }}</span>{{ meter(s.disk_percent, "disk_percent") }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Netwerk</h2>
|
||||
<dl class="kv">
|
||||
<dt>Ethernet</dt><dd data-field="eth0">{{ s.eth0 }}</dd>
|
||||
<dt>Wifi</dt><dd data-field="wlan0">{{ s.wlan0 }}</dd>
|
||||
<dt>Hostname</dt><dd>{{ hostname }}.local</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Over dit apparaat</h2>
|
||||
<dl class="kv">
|
||||
<dt>Naam</dt><dd>{{ device_name }}</dd>
|
||||
<dt>Versie</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
|
||||
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
|
||||
<dt>Hardware</dt><dd>{{ s.model }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="card card-narrow">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import badge, csrf with context %}
|
||||
{% set title = "Kiosk" %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>Kioskbeheer</h1>
|
||||
{{ badge(s.browser.status, s.browser.label) }}
|
||||
</div>
|
||||
|
||||
|
||||
<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 Daynivo) wordt elders afgeschermd getoond.</small>
|
||||
</label>
|
||||
<label>Apparaatnaam
|
||||
<input type="text" name="device_name" value="{{ conf.device_name }}" required maxlength="64">
|
||||
</label>
|
||||
<label>Automatisch verversen
|
||||
<select name="refresh_interval">
|
||||
{% set intervals = [(0, "Uit"), (60, "Elke minuut"), (300, "Elke 5 minuten"), (900, "Elk kwartier"), (1800, "Elk half uur"), (3600, "Elk uur"), (21600, "Elke 6 uur"), (86400, "Elke dag")] %}
|
||||
{% set known = intervals|map(attribute=0)|list %}
|
||||
{% for value, label in intervals %}<option value="{{ value }}" {% if conf.refresh_interval == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
{% if conf.refresh_interval not in known %}<option value="{{ conf.refresh_interval }}" selected>Elke {{ conf.refresh_interval }} seconden</option>{% endif %}
|
||||
</select>
|
||||
<small>Wordt overgeslagen zolang de URL niet bereikbaar is, zodat een offline werkende pagina blijft staan.</small>
|
||||
</label>
|
||||
<label>Browserprofiel
|
||||
<select name="browser_profile">
|
||||
<option value="persistent" {% if conf.browser_profile == 'persistent' %}selected{% endif %}>Bewaren (cookies en offline-cache blijven na herstart)</option>
|
||||
<option value="ephemeral" {% if conf.browser_profile == 'ephemeral' %}selected{% endif %}>Alleen in werkgeheugen (schoon bij iedere start)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="browser_restart" value="1" {% if conf.browser_restart %}checked{% endif %}> Chromium automatisch herstarten na een crash</label>
|
||||
<button type="submit" class="btn btn-primary">Opslaan</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Huidig scherm</h2>
|
||||
<div class="screenshot" data-screenshot="{{ url_for('ui.kiosk_screenshot') }}">
|
||||
<p class="muted" data-screenshot-msg>Klik op ‘Screenshot maken’ om te zien wat de kiosk nu toont.</p>
|
||||
<img alt="Screenshot van het kioskscherm" hidden>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" data-screenshot-btn>Screenshot maken</button>
|
||||
<dl class="kv kv-tight">
|
||||
<dt>Ingestelde URL</dt><dd class="break">{{ masked_url }}</dd>
|
||||
<dt>Getoonde pagina</dt><dd class="break">{{ agent.page_url or "–" }}</dd>
|
||||
<dt>Laatste controle</dt><dd>{% if agent.checked_at %}<time data-epoch="{{ agent.checked_at }}"></time> — {% if agent.target_reachable %}bereikbaar{% else %}{{ agent.reason or "niet bereikbaar" }}{% endif %}{% else %}–{% endif %}</dd>
|
||||
{% if agent.offline_since %}<dt>Offline sinds</dt><dd><time data-epoch="{{ agent.offline_since }}"></time></dd>{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Laatste meldingen</h2>
|
||||
{% if errors %}
|
||||
<pre class="log log-short">{% for line in errors %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
{% else %}
|
||||
<p class="muted">Geen waarschuwingen of fouten.</p>
|
||||
{% endif %}
|
||||
<p><a href="{{ url_for('ui.logs', source='kiosk') }}">Alle kiosklogs bekijken →</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% set title = "Inloggen" %}
|
||||
{% block content %}
|
||||
<section class="card card-narrow">
|
||||
<h1>Inloggen</h1>
|
||||
<p class="muted">{{ device_name }} · {{ hostname }}</p>
|
||||
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
|
||||
<form method="post" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Beheerderswachtwoord
|
||||
<input type="password" name="password" required autofocus autocomplete="current-password" maxlength="128">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">Inloggen</button>
|
||||
</form>
|
||||
<p class="muted small">Wachtwoord vergeten? Log via SSH in en voer <code>sudo daynivo-kiosk-ctl reset-password</code> uit.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% set title = "Logboek" %}
|
||||
{% block content %}
|
||||
<div class="page-head"><h1>Logboek</h1></div>
|
||||
<section class="card">
|
||||
<form method="get" class="form form-inline">
|
||||
<label>Bron
|
||||
<select name="source">
|
||||
{% for key, item in sources.items() %}<option value="{{ key }}" {% if key == source %}selected{% endif %}>{{ item[0] }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Regels
|
||||
<select name="lines">
|
||||
{% for n in line_options %}<option value="{{ n }}" {% if n == lines %}selected{% endif %}>{{ n }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Niveau
|
||||
<select name="priority">
|
||||
<option value="" {% if not priority %}selected{% endif %}>Alles</option>
|
||||
<option value="warning" {% if priority == 'warning' %}selected{% endif %}>Waarschuwingen en fouten</option>
|
||||
<option value="err" {% if priority == 'err' %}selected{% endif %}>Alleen fouten</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Tonen</button>
|
||||
</form>
|
||||
<pre class="log" data-scroll-bottom>{{ text }}</pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% set title = "Beheerinterface herstart" %}
|
||||
{% block content %}
|
||||
<section class="card card-narrow" data-redirect="{{ target }}" data-redirect-after="8">
|
||||
<h1>Beheerinterface herstart</h1>
|
||||
<p>De beheerinterface is straks bereikbaar op:</p>
|
||||
<p><a href="{{ target }}"><code>{{ target }}</code></a></p>
|
||||
<p class="muted">Je wordt over enkele seconden automatisch doorgestuurd. Bij HTTPS met een zelfondertekend
|
||||
certificaat vraagt je browser eenmalig om bevestiging.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import badge, csrf with context %}
|
||||
{% set title = "Netwerk" %}
|
||||
{% block content %}
|
||||
<div class="page-head"><h1>Netwerkbeheer</h1></div>
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card-pending" data-pending="{{ url_for('ui.api_network_pending') }}" data-remaining="{{ pending.remaining }}">
|
||||
<h2>Netwerkwijziging bevestigen</h2>
|
||||
<p>De nieuwe instellingen voor <strong>{{ 'ethernet' if pending.interface == 'eth0' else 'wifi' }}</strong>
|
||||
{% if pending.method == 'static' %}(statisch adres <strong>{{ pending.address }}</strong>){% else %}(DHCP){% endif %} zijn actief.
|
||||
Werkt de beheerinterface nog? Bevestig dan. Zonder bevestiging worden over
|
||||
<strong data-countdown>{{ pending.remaining }}</strong> seconden automatisch de vorige instellingen teruggezet.</p>
|
||||
{% if pending.method == 'static' %}
|
||||
<p class="muted">Is het adres veranderd? Open dan <code>{{ admin_scheme }}://{{ pending.address.split('/')[0] }}:{{ admin_port }}/network</code>, log in en bevestig daar.</p>
|
||||
{% endif %}
|
||||
<div class="actions-row">
|
||||
<form method="post" action="{{ url_for('ui.network_confirm') }}" class="inline-form">{{ csrf() }}<button class="btn btn-primary 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>daynivo-network-reset</code> op de bootpartitie van de SD-kaart en start de Pi. Alle Daynivo-netwerkprofielen worden dan verwijderd (terug naar DHCP).</li>
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% set title = "Wachtwoord wijzigen" %}
|
||||
{% block content %}
|
||||
<section class="card card-narrow">
|
||||
<h1>Wachtwoord wijzigen</h1>
|
||||
<form method="post" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Huidig wachtwoord
|
||||
<input type="password" name="current_password" required autocomplete="current-password" maxlength="128">
|
||||
</label>
|
||||
<label>Nieuw wachtwoord
|
||||
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
|
||||
</label>
|
||||
<label>Herhaal nieuw wachtwoord
|
||||
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">Wijzigen</button>
|
||||
</form>
|
||||
<p class="muted small">Na het wijzigen worden alle andere sessies uitgelogd.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% set title = "Eerste configuratie" %}
|
||||
{% block content %}
|
||||
<section class="card card-narrow">
|
||||
<h1>Welkom bij Daynivo 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 daynivo-kiosk-ctl setup-code</code>).</p>
|
||||
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
|
||||
<form method="post" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Setupcode
|
||||
<input type="text" name="setup_code" required autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="ABCD-EFGH" maxlength="12" class="input-code">
|
||||
</label>
|
||||
<label>Nieuw wachtwoord
|
||||
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
|
||||
<small>Minimaal 10 tekens. Gebruik voor iedere kiosk een eigen wachtwoord.</small>
|
||||
</label>
|
||||
<label>Herhaal wachtwoord
|
||||
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">Wachtwoord instellen</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import csrf with context %}
|
||||
{% set title = "Systeem" %}
|
||||
{% block content %}
|
||||
<div class="page-head"><h1>Systeembeheer</h1></div>
|
||||
|
||||
|
||||
<div class="grid grid-2">
|
||||
<section class="card">
|
||||
<h2>Scherm</h2>
|
||||
<form method="post" action="{{ url_for('ui.system_display') }}" class="form">
|
||||
{{ csrf() }}
|
||||
<label>Rotatie
|
||||
<select name="rotation">
|
||||
{% for deg, label in [(0, "0° (normaal)"), (90, "90° (rechtsom)"), (180, "180° (ondersteboven)"), (270, "270° (linksom)")] %}
|
||||
<option value="{{ deg }}" {% if conf.rotation == deg %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Resolutie
|
||||
<select name="resolution">
|
||||
<option value="auto" {% if conf.resolution == 'auto' %}selected{% endif %}>Automatisch (voorkeur van het scherm)</option>
|
||||
{% for mode in modes %}<option value="{{ mode }}" {% if conf.resolution == mode %}selected{% endif %}>{{ mode }}</option>{% endfor %}
|
||||
{% if conf.resolution != 'auto' and conf.resolution not in modes %}<option value="{{ conf.resolution }}" selected>{{ conf.resolution }}</option>{% endif %}
|
||||
</select>
|
||||
<small>{% if output %}Aangesloten: {{ output.name }}{% if output.current %}, nu {{ output.current }}{% endif %}.{% else %}Schermgegevens verschijnen zodra de kiosk draait.{% endif %}</small>
|
||||
</label>
|
||||
<label>Zoomfactor
|
||||
<input type="number" name="zoom" min="0.25" max="4" step="0.05" value="{{ conf.zoom }}">
|
||||
<small>1 = normaal. Groter maakt alles groter (handig op grote tv's).</small>
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="screen_always_on" value="1" {% if conf.screen_always_on %}checked{% endif %} data-toggle-target="#screen-off"> Scherm altijd aan (geen screensaver of energiebeheer)</label>
|
||||
<label id="screen-off">Scherm uitschakelen na (minuten zonder invoer)
|
||||
<input type="number" name="screen_off_minutes" min="1" max="240" value="{{ conf.screen_off_minutes }}">
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="apply_now" value="1" checked> Direct toepassen (Chromium herstart)</label>
|
||||
<button type="submit" class="btn btn-primary">Opslaan</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Hostname</h2>
|
||||
<form method="post" action="{{ url_for('ui.system_hostname') }}" class="form">
|
||||
{{ csrf() }}
|
||||
<label>Hostname
|
||||
<input type="text" name="hostname" value="{{ hostname }}" required maxlength="63" pattern="[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?" autocomplete="off" spellcheck="false">
|
||||
<small>De beheerinterface is daarna bereikbaar via <code>http://<hostname>.local:{{ conf.admin_port }}</code>. Geef iedere kiosk op hetzelfde netwerk een unieke naam.</small>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Hostname wijzigen</button>
|
||||
</form>
|
||||
|
||||
<h2 class="mt">Beheerinterface</h2>
|
||||
<form method="post" action="{{ url_for('ui.system_admin') }}" class="form" data-confirm="Als je poort of HTTPS wijzigt, herstart de beheerinterface en word je doorgestuurd naar het nieuwe adres." data-confirm-title="Beheerinterface aanpassen">
|
||||
{{ csrf() }}
|
||||
<label>Poort
|
||||
<input type="number" name="admin_port" min="1024" max="65535" value="{{ conf.admin_port }}">
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="admin_https" value="1" {% if conf.admin_https %}checked{% endif %}> HTTPS gebruiken (zelfondertekend certificaat)</label>
|
||||
<label class="check"><input type="checkbox" name="admin_allow_public" value="1" {% if conf.admin_allow_public %}checked{% endif %}> Toegang vanaf publieke IP-adressen toestaan <span class="muted">(afgeraden; gebruik liever VPN)</span></label>
|
||||
<button type="submit" class="btn btn-secondary">Opslaan</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card" id="updates">
|
||||
<h2>Software-updates</h2>
|
||||
<dl class="kv">
|
||||
<dt>Geïnstalleerd</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
|
||||
<dt>Nieuwste versie</dt>
|
||||
<dd>
|
||||
{% if update.check_error %}<span class="muted">Onbekend — controleren mislukt: {{ update.check_error }}</span>
|
||||
{% elif update.latest %}{{ update.latest }}{% if update.available %} <strong>— beschikbaar</strong>{% else %} <span class="muted">(je bent bij)</span>{% endif %}
|
||||
{% else %}<span class="muted">Nog niet gecontroleerd</span>{% endif %}
|
||||
</dd>
|
||||
{% if update.checked %}<dt>Gecontroleerd</dt><dd>{{ update.checked }}</dd>{% endif %}
|
||||
{% if update.running %}<dt>Status</dt><dd>Bezig met bijwerken naar {{ update.target }}…</dd>
|
||||
{% elif update.status == 'failed' %}<dt>Laatste update</dt><dd>Mislukt{% if update.finished %} op {{ update.finished }}{% endif %}: {{ update.install_error }}</dd>
|
||||
{% elif update.status == 'done' and update.finished %}<dt>Laatste update</dt><dd>Gelukt op {{ update.finished }}</dd>{% endif %}
|
||||
</dl>
|
||||
|
||||
<form method="post" action="{{ url_for('ui.system_updates') }}" class="form mt-sm">
|
||||
{{ csrf() }}
|
||||
<label class="check"><input type="checkbox" name="auto_update" value="1" {% if conf.auto_update %}checked{% endif %}> Automatisch bijwerken <span class="muted">(elke nacht tussen 03:00 en 05:00, als er een nieuwe versie is)</span></label>
|
||||
<button type="submit" class="btn btn-secondary">Opslaan</button>
|
||||
</form>
|
||||
|
||||
<div class="actions-row mt-sm">
|
||||
<form method="post" action="{{ url_for('ui.system_update_check') }}" class="inline-form">{{ csrf() }}<button type="submit" class="btn btn-secondary">Nu controleren</button></form>
|
||||
{% if update.available and not update.running %}
|
||||
<form method="post" action="{{ url_for('ui.system_update_install') }}" class="inline-form"
|
||||
data-confirm="Daynivo Kiosk OS wordt bijgewerkt naar {{ update.latest }}. Het scherm en de beheerinterface zijn een paar minuten niet beschikbaar." data-confirm-title="Nu bijwerken">
|
||||
{{ csrf() }}<button type="submit" class="btn btn-primary">Bijwerken naar {{ update.latest }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="muted small">De configuratie blijft behouden en wordt voor elke update eerst bewaard in <code>/var/backups</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Systeeminformatie</h2>
|
||||
<dl class="kv">
|
||||
<dt>Versie</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
|
||||
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
|
||||
<dt>Hardware</dt><dd>{{ s.model }}</dd>
|
||||
<dt>Uptime</dt><dd>{{ s.uptime }}</dd>
|
||||
</dl>
|
||||
<p><a href="{{ url_for('ui.logs', source='system') }}">Systeemlogs bekijken →</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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", []))
|
||||
Reference in new issue
Block a user