From 07d5effcec8f2a4d63828ef34018e634d0f113b1 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 6 Aug 2026 23:32:10 +0200 Subject: [PATCH] feat(apps): eigen mappen en apparaten per app instelbaar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Een mediamap koppelen of een USB-stick doorgeven kon alleen door het compose-bestand met de hand te bewerken, en dat werd bij de eerstvolgende 'Instellingen wijzigen' overschreven. - Nieuwe stap 'Extra's' in het invulmenu: per onderdeel een map van de host (optioneel alleen lezen) of een apparaat. - De keuzes staan in .serverup.json en worden ná het renderen toegepast, dus ze overleven elke wijziging. - /api/devices vindt seriële poorten (/dev/serial/by-id, ttyUSB*, ttyACM*) en videokaarten, met een 'vast'-label voor by-id-paden. - Het hostpad wordt vooraf gecontroleerd via /api/paths/check. - Docker-socket, /etc, /proc, /sys en /boot worden geweigerd met uitleg, in compose_transform en nog eens in de API. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT --- CHANGELOG.md | 22 +++++ VERSION | 2 +- server-up/app.py | 120 ++++++++++++++++++++++++- server-up/core/compose_transform.py | 101 +++++++++++++++++++++ server-up/templates/index.html | 134 +++++++++++++++++++++++++++- server-up/translations/en.json | 12 +++ server-up/translations/nl.json | 12 +++ tests/js/menu.test.js | 4 +- tests/test_compose_transform.py | 94 +++++++++++++++++++ tests/test_reconfigure.py | 93 +++++++++++++++++++ 10 files changed, 588 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 957d35a..d85de3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +# v0.8.26-beta — Eigen mappen en apparaten per app + +Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders +staat, een USB-stick voor Zigbee, een videokaart om mee te transcoderen. Dat +kon alleen door het compose-bestand met de hand te bewerken — en dat werd bij +de eerstvolgende 'Instellingen wijzigen' weer overschreven. + +- **Nieuwe stap 'Extra's' in het invulmenu.** Per onderdeel een map van de host + koppelen (met een vinkje 'alleen lezen') of een apparaat doorgeven. +- **Ze overleven een wijziging.** De keuzes staan in `.serverup.json` en worden + ná het renderen opnieuw toegepast, net als de netwerkinstellingen. +- **Apparaten worden gevonden.** Server Up leest `/dev/serial/by-id`, + `ttyUSB*`, `ttyACM*` en `/dev/dri` uit en biedt ze aan met één klik. Een + `by-id`-pad blijft gelijk als je de stick in een andere poort steekt, en + krijgt daarom het label 'vast'. +- **Padcontrole vooraf.** Een map die Server Up niet kan bereiken meldt zich + meteen, in plaats van een lege mount in de container op te leveren. +- **Gevaarlijke mounts worden geweigerd.** De Docker-socket, `/etc`, `/proc`, + `/sys` en `/boot` gaan niet via dit scherm: die geven een container de macht + over de hele host. Je krijgt te horen waaróm, in plaats van een stille + correctie. + # v0.8.25-beta — Zichtbaar uit welk sjabloon een app komt Sjablonen komen uit de git-cache van een app-repo, niet uit het image van diff --git a/VERSION b/VERSION index 0a2307a..035af51 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.25-beta +0.8.26-beta diff --git a/server-up/app.py b/server-up/app.py index 94affa3..aa8b2f4 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -1,6 +1,6 @@ """Server Up — Docker Manager.""" from __future__ import annotations -import inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time +import glob, inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time from datetime import datetime, timedelta from pathlib import Path @@ -1114,6 +1114,7 @@ def api_stack_config(name): connected=bool(meta.get("connected")), app_networks=meta.get("app_networks") or [], app_services=meta.get("app_services") or [], + extras=meta.get("extras") or {"volumes": [], "devices": []}, source=meta.get("source", ""), repo_id=meta.get("repo_id", ""), name=meta.get("name", name), # Waar dit sjabloon vandaan komt. Opslaan rendert de app @@ -1177,6 +1178,9 @@ def api_stack_reconfigure(name): # Welke onderdelen op dat netwerk komen. Zonder keuze blijven databases er # buiten; zie compose_transform.add_shared_network. app_diensten = _gekozen_app_diensten(body, meta) + extras, extra_fout = _gekozen_extras(body, meta) + if extra_fout: + return jsonify(ok=False, msg=extra_fout), 400 # Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack # opleveren — en er ligt al een werkende versie die we niet moeten slopen. @@ -1261,6 +1265,21 @@ def api_stack_reconfigure(name): + ", ".join(f"'{n}'" for n in klaar) + " (" + ", ".join(diensten_nu) + ")") + # Eigen mappen en apparaten als laatste: die horen ook op een + # service die net een eigen IP of netwerk kreeg. + if cf and (extras.get("volumes") or extras.get("devices")): + try: + cf.write_text(compose_transform.add_extras( + cf.read_text(encoding="utf-8"), extras), encoding="utf-8") + for v in extras.get("volumes") or []: + jobs.log(qq, "ok", f"Map gekoppeld: {v['host']} → " + f"{v['service']}:{v['container']}") + for a in extras.get("devices") or []: + jobs.log(qq, "ok", f"Apparaat gekoppeld: {a['host']} → " + f"{a['service']}") + except compose_transform.TransformError as e: + jobs.log(qq, "warn", f"Extra's niet toegepast: {e}") + ok_cfg, err = docker.validate_compose(d) if not ok_cfg: jobs.log(qq, "error", f"Compose is ongeldig:\n{err}") @@ -1275,6 +1294,7 @@ def api_stack_reconfigure(name): "network": net_name, "ip": net_ip, "ips": net_ips, "app_networks": app_netten, "app_services": app_diensten, + "extras": extras, "service_ports": poorten_meta, "web": boilerplates.metadata(bron).get("web") or {}, "connected": bool(app_netten)}) @@ -1648,6 +1668,52 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]: return [standaard] if verbinden else [] +def _gekozen_extras(body: dict, meta: dict | None = None) -> tuple[dict, str]: + """Extra mappen en apparaten uit de request, gecontroleerd. + + Geeft (extras, foutmelding) terug. Een fout hier is een 400 en geen stille + correctie: wie een pad intikt dat niet mag, hoort te horen waarom in plaats + van een app te krijgen die dat pad niet heeft. + """ + if "extras" not in body: + rest = (meta or {}).get("extras") or {} + return (rest if isinstance(rest, dict) else {}), "" + ruw = body.get("extras") or {} + if not isinstance(ruw, dict): + return {}, "extras moet een object zijn" + + uit: dict[str, list] = {"volumes": [], "devices": []} + for item in (ruw.get("volumes") or [])[:50]: + if not isinstance(item, dict): + continue + host = str(item.get("host") or "").strip().rstrip("/") + binnen = str(item.get("container") or "").strip().rstrip("/") or host + dienst = str(item.get("service") or "").strip() + if not host and not dienst: + continue + bezwaar = compose_transform.mount_bezwaar(host) + if bezwaar: + return {}, bezwaar + if not binnen.startswith("/"): + return {}, f"Het pad binnen de container moet absoluut zijn: {binnen}" + uit["volumes"].append({"service": dienst, "host": host, + "container": binnen, "ro": bool(item.get("ro"))}) + for item in (ruw.get("devices") or [])[:50]: + if not isinstance(item, dict): + continue + host = str(item.get("host") or "").strip() + binnen = str(item.get("container") or "").strip() or host + dienst = str(item.get("service") or "").strip() + if not host and not dienst: + continue + bezwaar = compose_transform.apparaat_bezwaar(host) + if bezwaar: + return {}, bezwaar + uit["devices"].append({"service": dienst, "host": host, + "container": binnen}) + return uit, "" + + def _gekozen_app_diensten(body: dict, meta: dict | None = None) -> list[str]: """Welke onderdelen van deze stack mogen op het gedeelde netwerk? @@ -1976,6 +2042,9 @@ def api_store_install(): gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() app_netten = _gekozen_app_netwerken(d, gedeeld_net) app_diensten = _gekozen_app_diensten(d) + extras, extra_fout = _gekozen_extras(d) + if extra_fout: + return jsonify(ok=False, msg=extra_fout), 400 verbinden = bool(app_netten) if not stack: return jsonify(ok=False, msg="stack vereist") @@ -2250,6 +2319,21 @@ def api_store_install(): except compose_transform.TransformError as e: jobs.log(qq, "warn", f"Koppelen mislukt: {e}") + # Eigen mappen en apparaten uit het invulmenu. + if extras.get("volumes") or extras.get("devices"): + cf = docker.find_compose(dest) + try: + cf.write_text(compose_transform.add_extras( + cf.read_text(encoding="utf-8"), extras), encoding="utf-8") + for v in extras.get("volumes") or []: + jobs.log(qq, "ok", f"Map gekoppeld: {v['host']} → " + f"{v['service']}:{v['container']}") + for a in extras.get("devices") or []: + jobs.log(qq, "ok", f"Apparaat gekoppeld: {a['host']} → " + f"{a['service']}") + except compose_transform.TransformError as e: + jobs.log(qq, "warn", f"Extra's niet toegepast: {e}") + # Valideer het resultaat vóór we proberen te starten, zodat een # fout in het template een duidelijke melding geeft. ok_cfg, err_cfg = docker.validate_compose(dest) @@ -2288,6 +2372,7 @@ def api_store_install(): "connected": bool(app_netten), "app_networks": app_netten, "app_services": app_diensten, + "extras": extras, "service_ports": poorten_meta, "categories": meta.get("categories") or [], "tags": meta.get("tags") or [], @@ -3826,6 +3911,39 @@ def api_path_check(): return jsonify(**uitslag) +@app.route("/api/devices") +def api_devices(): + """Welke apparaten kun je aan een app doorgeven? + + Zelf `/dev/serial/by-id/` uitpluizen via ssh is precies het soort werk dat + deze interface hoort weg te nemen. Seriële poorten (USB-sticks voor Zigbee, + Z-Wave, P1) en videokaarten voor transcoderen. + """ + uit = [] + # by-id blijft gelijk als je hem in een andere poort steekt; ttyUSB0 niet. + for map_ in ("/dev/serial/by-id", "/dev/serial/by-path"): + p = Path(map_) + if not p.is_dir(): + continue + for kind in sorted(p.iterdir()): + try: + doel = os.path.realpath(kind) + except OSError: + doel = "" + uit.append({"path": str(kind), "kind": "serieel", + "target": doel, "stable": map_.endswith("by-id")}) + if uit: + break + for patroon, soort in (("/dev/ttyUSB*", "serieel"), ("/dev/ttyACM*", "serieel"), + ("/dev/dri/render*", "gpu"), ("/dev/dri/card*", "gpu"), + ("/dev/video*", "video"), ("/dev/bus/usb/*/*", "usb")): + for pad in sorted(glob.glob(patroon))[:40]: + if any(x["path"] == pad or x.get("target") == pad for x in uit): + continue + uit.append({"path": pad, "kind": soort, "target": "", "stable": False}) + return jsonify(devices=uit[:80], found=bool(uit)) + + @app.route("/api/paths/move", methods=["POST"]) def api_paths_move(): """Verplaats de mappen van Server Up, met de stacks erbij. diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py index 882818d..090ad9d 100644 --- a/server-up/core/compose_transform.py +++ b/server-up/core/compose_transform.py @@ -18,6 +18,8 @@ Voorbeeld: """ from __future__ import annotations +from pathlib import PurePosixPath + import yaml @@ -438,3 +440,102 @@ def published_ports(compose_text: str) -> list[str]: for p in svc.get("ports") or []: out.append(str(p)) return out + + +# ── Eigen mappen en apparaten ──────────────────────────────────────────────── +# Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders +# staat, een USB-stick voor Zigbee, een tekenkaart. Dat kon alleen door het +# compose-bestand met de hand te bewerken — en dat wordt bij de eerstvolgende +# 'Instellingen wijzigen' overschreven. Deze twee blokken worden juist ná het +# renderen toegepast, dus ze overleven dat. + +# Mounten van deze paden geeft de container de sleutels van de host: hij kan +# dan zijn eigen host overnemen. Niet aan te bieden in een keuzemenu. +VERBODEN_MOUNTS = ( + "/", "/etc", "/boot", "/proc", "/sys", "/dev", "/root", + "/var/run/docker.sock", "/run/docker.sock", "/var/lib/docker", +) + + +def mount_bezwaar(hostpad: str) -> str: + """Waarom mag dit pad niet gemount worden? Lege tekst = geen bezwaar.""" + p = (hostpad or "").strip().rstrip("/") or "/" + if not p.startswith("/"): + return "Gebruik een absoluut pad, beginnend met /." + if ".." in PurePosixPath(p).parts: + return "Een pad met '..' erin is niet toegestaan." + if p.endswith("docker.sock"): + return ("De Docker-socket geeft een container volledige macht over deze " + "host; dat gaat niet via dit scherm.") + for verboden in VERBODEN_MOUNTS: + # Ook alles eronder: /etc/ssh is net zo goed /etc. + if p == verboden or (verboden != "/" and p.startswith(verboden + "/")): + return f"{p} hoort bij het besturingssysteem en wordt niet gedeeld." + return "" + + +def apparaat_bezwaar(hostpad: str) -> str: + """Alleen echte apparaten uit /dev mogen doorgegeven worden.""" + p = (hostpad or "").strip() + if not p.startswith("/dev/") or p.rstrip("/") == "/dev": + return "Een apparaat begint met /dev/, bijvoorbeeld /dev/ttyUSB0." + if ".." in PurePosixPath(p).parts: + return "Een pad met '..' erin is niet toegestaan." + return "" + + +def _regels_toevoegen(doc: dict, service: str, sleutel: str, + nieuw: list[str]) -> int: + """Voeg regels toe aan `volumes` of `devices` van één service.""" + svc = (doc.get("services") or {}).get(service) + if not isinstance(svc, dict): + return 0 + huidig = svc.get(sleutel) + if not isinstance(huidig, list): + huidig = [] if huidig is None else [huidig] + toegevoegd = 0 + for regel in nieuw: + # Zelfde bronpad al aanwezig? Dan telt wat er staat; twee mounts op + # dezelfde plek laat compose alsnog stuklopen. + bron = regel.split(":", 1)[0] + if any(str(r).split(":", 1)[0] == bron for r in huidig): + continue + huidig.append(regel) + toegevoegd += 1 + if toegevoegd or sleutel not in svc: + svc[sleutel] = huidig + return toegevoegd + + +def add_extras(compose_text: str, extras: dict) -> str: + """Zet extra mappen en apparaten in het gerenderde compose-bestand. + + `extras` = {"volumes": [{service, host, container, ro}], + "devices": [{service, host, container}]} + Onbekende services worden overgeslagen: een sjabloon kan hernoemd zijn en + dat mag geen kapot compose-bestand opleveren. + """ + if not extras or not (extras.get("volumes") or extras.get("devices")): + return compose_text + doc = _laad(compose_text) + if not isinstance(doc.get("services"), dict): + raise TransformError("compose bevat geen services") + + for item in extras.get("volumes") or []: + host = str(item.get("host") or "").strip().rstrip("/") + binnen = str(item.get("container") or "").strip().rstrip("/") or host + if not host or mount_bezwaar(host): + continue + regel = f"{host}:{binnen}" + (":ro" if item.get("ro") else "") + _regels_toevoegen(doc, str(item.get("service") or ""), "volumes", [regel]) + + for item in extras.get("devices") or []: + host = str(item.get("host") or "").strip() + binnen = str(item.get("container") or "").strip() or host + if not host or apparaat_bezwaar(host): + continue + _regels_toevoegen(doc, str(item.get("service") or ""), "devices", + [f"{host}:{binnen}"]) + + return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False, + allow_unicode=True) diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 223b269..e5b188b 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -2012,6 +2012,96 @@ tailwind.config = { + + +