feat(apps): eigen mappen en apparaten per app instelbaar
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m57s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
This commit is contained in:
Ramon 2026-08-06 23:32:10 +02:00
parent b40a6a5a4c
commit 07d5effcec
10 changed files with 588 additions and 6 deletions

View file

@ -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 # 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 Sjablonen komen uit de git-cache van een app-repo, niet uit het image van

View file

@ -1 +1 @@
0.8.25-beta 0.8.26-beta

View file

@ -1,6 +1,6 @@
"""Server Up — Docker Manager.""" """Server Up — Docker Manager."""
from __future__ import annotations 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 datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@ -1114,6 +1114,7 @@ def api_stack_config(name):
connected=bool(meta.get("connected")), connected=bool(meta.get("connected")),
app_networks=meta.get("app_networks") or [], app_networks=meta.get("app_networks") or [],
app_services=meta.get("app_services") 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", ""), source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name), name=meta.get("name", name),
# Waar dit sjabloon vandaan komt. Opslaan rendert de app # 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 # Welke onderdelen op dat netwerk komen. Zonder keuze blijven databases er
# buiten; zie compose_transform.add_shared_network. # buiten; zie compose_transform.add_shared_network.
app_diensten = _gekozen_app_diensten(body, meta) 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 # Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen. # 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(f"'{n}'" for n in klaar)
+ " (" + ", ".join(diensten_nu) + ")") + " (" + ", ".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) ok_cfg, err = docker.validate_compose(d)
if not ok_cfg: if not ok_cfg:
jobs.log(qq, "error", f"Compose is ongeldig:\n{err}") 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, "network": net_name, "ip": net_ip, "ips": net_ips,
"app_networks": app_netten, "app_networks": app_netten,
"app_services": app_diensten, "app_services": app_diensten,
"extras": extras,
"service_ports": poorten_meta, "service_ports": poorten_meta,
"web": boilerplates.metadata(bron).get("web") or {}, "web": boilerplates.metadata(bron).get("web") or {},
"connected": bool(app_netten)}) "connected": bool(app_netten)})
@ -1648,6 +1668,52 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
return [standaard] if verbinden else [] 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]: def _gekozen_app_diensten(body: dict, meta: dict | None = None) -> list[str]:
"""Welke onderdelen van deze stack mogen op het gedeelde netwerk? """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() gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_netten = _gekozen_app_netwerken(d, gedeeld_net) app_netten = _gekozen_app_netwerken(d, gedeeld_net)
app_diensten = _gekozen_app_diensten(d) 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) verbinden = bool(app_netten)
if not stack: if not stack:
return jsonify(ok=False, msg="stack vereist") return jsonify(ok=False, msg="stack vereist")
@ -2250,6 +2319,21 @@ def api_store_install():
except compose_transform.TransformError as e: except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Koppelen mislukt: {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 # Valideer het resultaat vóór we proberen te starten, zodat een
# fout in het template een duidelijke melding geeft. # fout in het template een duidelijke melding geeft.
ok_cfg, err_cfg = docker.validate_compose(dest) ok_cfg, err_cfg = docker.validate_compose(dest)
@ -2288,6 +2372,7 @@ def api_store_install():
"connected": bool(app_netten), "connected": bool(app_netten),
"app_networks": app_netten, "app_networks": app_netten,
"app_services": app_diensten, "app_services": app_diensten,
"extras": extras,
"service_ports": poorten_meta, "service_ports": poorten_meta,
"categories": meta.get("categories") or [], "categories": meta.get("categories") or [],
"tags": meta.get("tags") or [], "tags": meta.get("tags") or [],
@ -3826,6 +3911,39 @@ def api_path_check():
return jsonify(**uitslag) 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"]) @app.route("/api/paths/move", methods=["POST"])
def api_paths_move(): def api_paths_move():
"""Verplaats de mappen van Server Up, met de stacks erbij. """Verplaats de mappen van Server Up, met de stacks erbij.

View file

@ -18,6 +18,8 @@ Voorbeeld:
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import PurePosixPath
import yaml import yaml
@ -438,3 +440,102 @@ def published_ports(compose_text: str) -> list[str]:
for p in svc.get("ports") or []: for p in svc.get("ports") or []:
out.append(str(p)) out.append(str(p))
return out 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)

View file

@ -2012,6 +2012,96 @@ tailwind.config = {
</div> </div>
</template> </template>
<!-- Extra's: een map van de host en een USB-apparaat erbij.
Dat kon alleen door het compose-bestand met de hand te
bewerken, en dat wordt bij de eerstvolgende wijziging weer
overschreven. -->
<template x-if="s.id === 'extras'">
<div class="space-y-4">
<div>
<div class="form-label" x-text="t('extra_volumes')">Mappen van deze host</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('extra_volumes_hint')"></p>
<template x-for="(v, i) in (install.extras.volumes || [])" :key="'v'+i">
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
<select class="form-input !h-9 !text-xs w-auto min-w-[7rem]" x-model="v.service">
<template x-for="svc in (install.service_info||[])" :key="svc.name">
<option :value="svc.name" x-text="svc.name"></option>
</template>
</select>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[9rem]"
x-model="v.host" :placeholder="t('host_path')"
@change="checkExtraPad(v)">
<span class="mdi mdi-arrow-right text-dm dark:text-dm-dark"></span>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[8rem]"
x-model="v.container" placeholder="/media">
<label class="flex items-center gap-1.5 text-[11px] cursor-pointer px-1">
<input type="checkbox" x-model="v.ro"
class="w-4 h-4 rounded border-bd dark:border-bd-dark text-ac">
<span x-text="t('read_only')">alleen lezen</span>
</label>
<button type="button" class="btn-sm !h-9 !px-2 btn-danger"
@click="install.extras.volumes.splice(i, 1)">
<span class="mdi mdi-delete-outline"></span>
</button>
<p x-show="v.fout" class="w-full text-[11px] text-er" x-text="v.fout"></p>
</div>
</template>
<button type="button" class="btn-sm mt-1" @click="voegMapToe()">
<span class="mdi mdi-folder-plus-outline"></span>
<span x-text="t('add_volume')">Map toevoegen</span>
</button>
</div>
<div>
<div class="form-label" x-text="t('extra_devices')">Apparaten</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('extra_devices_hint')"></p>
<template x-for="(a, i) in (install.extras.devices || [])" :key="'d'+i">
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
<select class="form-input !h-9 !text-xs w-auto min-w-[7rem]" x-model="a.service">
<template x-for="svc in (install.service_info||[])" :key="svc.name">
<option :value="svc.name" x-text="svc.name"></option>
</template>
</select>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[12rem]"
x-model="a.host" list="apparatenlijst" placeholder="/dev/ttyUSB0">
<span class="mdi mdi-arrow-right text-dm dark:text-dm-dark"></span>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[8rem]"
x-model="a.container" :placeholder="a.host || '/dev/ttyACM0'">
<button type="button" class="btn-sm !h-9 !px-2 btn-danger"
@click="install.extras.devices.splice(i, 1)">
<span class="mdi mdi-delete-outline"></span>
</button>
</div>
</template>
<datalist id="apparatenlijst">
<template x-for="dev in devices" :key="dev.path">
<option :value="dev.path"></option>
</template>
</datalist>
<button type="button" class="btn-sm mt-1" @click="voegApparaatToe()">
<span class="mdi mdi-usb"></span>
<span x-text="t('add_device')">Apparaat toevoegen</span>
</button>
<!-- De gevonden apparaten met één klik, want /dev/serial/by-id
zelf uitpluizen via ssh is precies wat dit scherm
overbodig hoort te maken. -->
<div x-show="devices.length" class="mt-2 space-y-1">
<template x-for="dev in devices.slice(0, 12)" :key="dev.path">
<button type="button"
class="w-full text-left px-2.5 py-1.5 rounded-lg border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40 hover:border-ac/40"
@click="voegApparaatToe(dev.path)">
<span class="font-mono text-[11px] text-tx dark:text-tx-dark" x-text="dev.path"></span>
<span x-show="dev.stable" class="badge badge-ok ml-1.5" x-text="t('device_stable')">vast</span>
<span x-show="dev.target" class="text-[10px] text-dm dark:text-dm-dark ml-1.5" x-text="'→ ' + dev.target"></span>
</button>
</template>
</div>
<p x-show="!devices.length" class="text-[11px] text-dm dark:text-dm-dark mt-2"
x-text="t('no_devices')"></p>
</div>
</div>
</template>
<!-- Controleren: het gerenderde resultaat, bewerkbaar --> <!-- Controleren: het gerenderde resultaat, bewerkbaar -->
<template x-if="s.id === 'controleren'"> <template x-if="s.id === 'controleren'">
<div class="space-y-2"> <div class="space-y-2">
@ -2720,6 +2810,7 @@ function app() {
newRepo: { name:'', url:'', branch:'main', subdir:'apps' }, newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
repoEdit: { id:'', name:'', url:'', branch:'', subdir:'' }, repoEdit: { id:'', name:'', url:'', branch:'', subdir:'' },
devices: [],
install: { install: {
stack: '', repo_id: '', instance: '', stack: '', repo_id: '', instance: '',
format: 'compose', loading: false, format: 'compose', loading: false,
@ -3356,6 +3447,37 @@ function app() {
this.appNets = d.networks || []; this.appNets = d.networks || [];
if (!this.newNetName) this.newNetName = d.suggested || ''; if (!this.newNetName) this.newNetName = d.suggested || '';
}, },
// ── Eigen mappen en apparaten ────────────────────────────────────────────
async loadDevices() {
const d = await this.rpc('/api/devices');
this.devices = (d && d.devices) || [];
},
_eersteDienst() {
const info = this.install.service_info || [];
const echte = info.find(s => !s.helper) || info[0];
return (echte || {}).name || '';
},
voegMapToe() {
if (!Array.isArray(this.install.extras.volumes)) this.install.extras.volumes = [];
this.install.extras.volumes.push(
{ service: this._eersteDienst(), host: '', container: '', ro: false });
},
voegApparaatToe(pad = '') {
if (!Array.isArray(this.install.extras.devices)) this.install.extras.devices = [];
if (pad && this.install.extras.devices.some(a => a.host === pad)) return;
this.install.extras.devices.push(
{ service: this._eersteDienst(), host: pad, container: '' });
},
// Bestaat dit pad op de host, en komt Server Up er überhaupt bij? Een map
// die alleen binnen de container bestaat levert een lege bind-mount op.
async checkExtraPad(v) {
v.fout = '';
if (!v.host) return;
if (!v.container) v.container = v.host;
const d = await this.rpc('/api/paths/check?path=' + encodeURIComponent(v.host));
if (d && !d.ok) v.fout = d.msg || this.t('path_unreachable');
},
// Zonder eigen keuze bepaalt de server het: alles behalve databases en // Zonder eigen keuze bepaalt de server het: alles behalve databases en
// zoekindexen. Dat spiegelen we hier, zodat de vinkjes kloppen met wat er // zoekindexen. Dat spiegelen we hier, zodat de vinkjes kloppen met wat er
// straks in het compose-bestand belandt. // straks in het compose-bestand belandt.
@ -3868,6 +3990,7 @@ function app() {
step: 0, tab: 'compose', editing: false, composeText: '', envText: '', step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
network: '', ip: '', ips: {}, services: [], service_info: [], network: '', ip: '', ips: {}, services: [], service_info: [],
app_services: [], depends_on: [], app_services: [], depends_on: [],
extras: { volumes: [], devices: [] },
compatibility: { known: false, supported: true, architecture: '', reason: '' }, compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: '' architecture_note: ''
}; };
@ -3951,6 +4074,7 @@ function app() {
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' }, verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' }, instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' }, geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
extras: { icon: 'mdi-usb-flash-drive-outline', key: 'step_extras' },
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' }, controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
}, },
wizardActief() { wizardActief() {
@ -3972,6 +4096,7 @@ function app() {
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg. // 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id)); if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
} }
uit.push(this._stap('extras'));
uit.push(this._stap('controleren')); uit.push(this._stap('controleren'));
return uit; return uit;
}, },
@ -4025,7 +4150,7 @@ function app() {
stepMissing() { stepMissing() {
if (!this.wizardActief()) return []; if (!this.wizardActief()) return [];
const s = this.wizardSteps()[this.install.step]; const s = this.wizardSteps()[this.install.step];
if (!s || s.id === 'controleren' || s.id === 'verbinden') return []; if (!s || ['controleren', 'verbinden', 'extras'].includes(s.id)) return [];
return this.stepFields(s.id).filter(f => { return this.stepFields(s.id).filter(f => {
if (!f.required || f.is_group_toggle) return false; if (!f.required || f.is_group_toggle) return false;
if (f.group_toggle && !this.install.values[f.group_toggle]) return false; if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
@ -4044,7 +4169,8 @@ function app() {
const stapId = stappen[this.install.step].id; const stapId = stappen[this.install.step].id;
// Ook bij Verbinden: daar staat de lijst containers die uit de // Ook bij Verbinden: daar staat de lijst containers die uit de
// voorvertoning komt, en die verandert met elk onderdeel dat je aanzet. // voorvertoning komt, en die verandert met elk onderdeel dat je aanzet.
if (['controleren', 'verbinden'].includes(stapId)) await this.refreshPreview(); if (['controleren', 'verbinden', 'extras'].includes(stapId)) await this.refreshPreview();
if (stapId === 'extras' && !this.devices.length) await this.loadDevices();
// Meteen laten zien of de poorten van deze stap vrij zijn. // Meteen laten zien of de poorten van deze stap vrij zijn.
this.checkPoortenVanStap(stapId); this.checkPoortenVanStap(stapId);
}, },
@ -4120,6 +4246,8 @@ function app() {
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []), app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
app_services: d.app_services || [], service_info: [], app_services: d.app_services || [], service_info: [],
source_repo: d.source_repo || {}, source_repo: d.source_repo || {},
extras: { volumes: (d.extras||{}).volumes || [],
devices: (d.extras||{}).devices || [] },
}; };
await this.loadAppNets(stack); await this.loadAppNets(stack);
this.fieldInfo = null; this.fieldInfo = null;
@ -4142,6 +4270,7 @@ function app() {
connect_apps: this.install.connect_apps !== false, connect_apps: this.install.connect_apps !== false,
app_networks: this.install.app_networks || [], app_networks: this.install.app_networks || [],
app_services: this.install.app_services || [], app_services: this.install.app_services || [],
extras: this.install.extras || {},
}); });
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; } if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null; this.modal = null;
@ -4176,6 +4305,7 @@ function app() {
body.connect_apps = this.install.connect_apps !== false; body.connect_apps = this.install.connect_apps !== false;
body.app_networks = this.install.app_networks || []; body.app_networks = this.install.app_networks || [];
body.app_services = this.install.app_services || []; body.app_services = this.install.app_services || [];
body.extras = this.install.extras || {};
if (this.install.format === 'boilerplate') body.values = this.install.values; if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; } else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw // Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw

View file

@ -57,6 +57,18 @@
"cache_outdated": "This repo is on {version} while Server Up runs {current}. Sync it or pick another branch — otherwise saving renders an older template over your app.", "cache_outdated": "This repo is on {version} while Server Up runs {current}. Sync it or pick another branch — otherwise saving renders an older template over your app.",
"cache_outdated_hint": "The templates in this repo lag behind the running version of Server Up.", "cache_outdated_hint": "The templates in this repo lag behind the running version of Server Up.",
"just_now": "just now", "just_now": "just now",
"step_extras": "Extras",
"extra_volumes": "Folders from this host",
"extra_volumes_hint": "Make a folder from the server available inside the app — your media library, a disk with photos. Survives later settings changes.",
"extra_devices": "Devices",
"extra_devices_hint": "Pass through a USB stick or graphics card, for example a Zigbee stick or a GPU for transcoding.",
"add_volume": "Add folder",
"add_device": "Add device",
"host_path": "/srv/media",
"read_only": "read only",
"device_stable": "stable",
"no_devices": "No devices found on this host. You can type the path yourself, for example /dev/ttyUSB0.",
"path_unreachable": "Server Up cannot reach this path; check that the folder is mounted.",
"add_repo": "Add repository", "add_repo": "Add repository",
"repo_name": "Name", "repo_name": "Name",
"repo_url": "Git URL", "repo_url": "Git URL",

View file

@ -57,6 +57,18 @@
"cache_outdated": "Deze repo staat op {version} terwijl Server Up {current} draait. Synchroniseer hem of kies een andere branch — anders rendert opslaan een ouder sjabloon over je app heen.", "cache_outdated": "Deze repo staat op {version} terwijl Server Up {current} draait. Synchroniseer hem of kies een andere branch — anders rendert opslaan een ouder sjabloon over je app heen.",
"cache_outdated_hint": "De sjablonen in deze repo lopen achter op de draaiende versie van Server Up.", "cache_outdated_hint": "De sjablonen in deze repo lopen achter op de draaiende versie van Server Up.",
"just_now": "zojuist", "just_now": "zojuist",
"step_extras": "Extra's",
"extra_volumes": "Mappen van deze host",
"extra_volumes_hint": "Een map van de server binnen de app beschikbaar maken — je mediabibliotheek, een schijf met foto's. Blijft staan als je later instellingen wijzigt.",
"extra_devices": "Apparaten",
"extra_devices_hint": "Een USB-stick of videokaart doorgeven, bijvoorbeeld een Zigbee-stick of een GPU voor transcoderen.",
"add_volume": "Map toevoegen",
"add_device": "Apparaat toevoegen",
"host_path": "/srv/media",
"read_only": "alleen lezen",
"device_stable": "vast",
"no_devices": "Geen apparaten gevonden op deze host. Je kunt het pad zelf intikken, bijvoorbeeld /dev/ttyUSB0.",
"path_unreachable": "Server Up kan hier niet bij; controleer of deze map gemount is.",
"add_repo": "Repository toevoegen", "add_repo": "Repository toevoegen",
"repo_name": "Naam", "repo_name": "Naam",
"repo_url": "Git URL", "repo_url": "Git URL",

View file

@ -40,8 +40,8 @@ c.install = {format:'boilerplate', loading:false, fields:[
], values:{}, step:0}; ], values:{}, step:0};
c.onePage = false; c.showAdvanced = false; c.onePage = false; c.showAdvanced = false;
let s = c.wizardSteps().map(x => x.id); let s = c.wizardSteps().map(x => x.id);
check('eenvoudige app: basis, verbinden, controleren', check('eenvoudige app: basis, verbinden, extra\'s, controleren',
JSON.stringify(s) === '["basis","verbinden","controleren"]'); JSON.stringify(s) === '["basis","verbinden","extras","controleren"]');
// ── App met geheimen ──────────────────────────────────────────────────────── // ── App met geheimen ────────────────────────────────────────────────────────
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true})); c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));

View file

@ -158,3 +158,97 @@ def test_onbekende_service_geeft_fout():
def test_published_ports(): def test_published_ports():
assert ct.published_ports(BASIS) == ["8222:80"] assert ct.published_ports(BASIS) == ["8222:80"]
assert ct.published_ports("services:\n a:\n image: x\n") == [] assert ct.published_ports("services:\n a:\n image: x\n") == []
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
EXTRA_BASIS = """\
services:
app:
image: nginx
volumes:
- /opt/serverup/appdata/app:/config
db:
image: postgres:16-alpine
"""
def test_datamap_wordt_toegevoegd():
"""Een mediamap die ergens anders staat kon alleen via het compose-bestand,
en dat wordt bij de eerstvolgende wijziging overschreven."""
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
{"service": "app", "host": "/srv/media", "container": "/media"}]})
doc = yaml.safe_load(uit)
assert "/srv/media:/media" in doc["services"]["app"]["volumes"]
# Wat er al stond blijft staan.
assert "/opt/serverup/appdata/app:/config" in doc["services"]["app"]["volumes"]
def test_datamap_alleen_lezen():
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
{"service": "app", "host": "/srv/foto", "container": "/foto", "ro": True}]})
assert "/srv/foto:/foto:ro" in yaml.safe_load(uit)["services"]["app"]["volumes"]
def test_apparaat_wordt_toegevoegd():
uit = ct.add_extras(EXTRA_BASIS, {"devices": [
{"service": "app", "host": "/dev/ttyUSB0", "container": "/dev/ttyACM0"}]})
assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
["/dev/ttyUSB0:/dev/ttyACM0"]
def test_apparaat_zonder_pad_binnen_de_container():
uit = ct.add_extras(EXTRA_BASIS, {"devices": [
{"service": "app", "host": "/dev/ttyUSB0"}]})
assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
["/dev/ttyUSB0:/dev/ttyUSB0"]
def test_extras_zijn_idempotent():
"""Elke wijziging rendert opnieuw en past dit opnieuw toe; twee mounts op
hetzelfde bronpad laat compose stuklopen."""
extras = {"volumes": [{"service": "app", "host": "/srv/media",
"container": "/media"}]}
een = ct.add_extras(EXTRA_BASIS, extras)
twee = ct.add_extras(een, extras)
volumes = yaml.safe_load(twee)["services"]["app"]["volumes"]
assert volumes.count("/srv/media:/media") == 1
def test_onbekende_service_wordt_overgeslagen():
"""Een hernoemd onderdeel mag geen kapot compose-bestand opleveren."""
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
{"service": "bestaatniet", "host": "/srv/x", "container": "/x"}]})
assert yaml.safe_load(uit)["services"].keys() == {"app", "db"}
def test_gevaarlijke_mounts_worden_geweigerd():
"""Een container die /etc of de Docker-socket mag lezen, is de host."""
for pad in ("/", "/etc", "/etc/ssh", "/var/run/docker.sock", "/proc/self",
"/boot", "relatief/pad", "/srv/../etc"):
assert ct.mount_bezwaar(pad), pad
for pad in ("/srv/media", "/mnt/schijf/films", "/opt/data"):
assert ct.mount_bezwaar(pad) == "", pad
def test_alleen_echte_apparaten():
for pad in ("/dev", "/srv/media", "/dev/../etc", "ttyUSB0", ""):
assert ct.apparaat_bezwaar(pad), pad
for pad in ("/dev/ttyUSB0", "/dev/serial/by-id/usb-abc", "/dev/dri/renderD128"):
assert ct.apparaat_bezwaar(pad) == "", pad
def test_geweigerd_pad_belandt_niet_in_compose():
"""De controle in de API is de eerste zeef; deze is de laatste."""
uit = ct.add_extras(EXTRA_BASIS, {
"volumes": [{"service": "app", "host": "/var/run/docker.sock",
"container": "/var/run/docker.sock"}],
"devices": [{"service": "app", "host": "/etc/shadow"}]})
doc = yaml.safe_load(uit)
assert "docker.sock" not in str(doc["services"]["app"].get("volumes"))
assert "devices" not in doc["services"]["app"]
def test_zonder_extras_verandert_er_niets():
assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS
assert ct.add_extras(EXTRA_BASIS, {"volumes": [], "devices": []}) == EXTRA_BASIS

View file

@ -290,3 +290,96 @@ def test_audit_endpoint_limiet_wordt_begrensd(client, env, gevuld):
login(client) login(client)
d = client.get("/api/audit?limit=99999").get_json() d = client.get("/api/audit?limit=99999").get_json()
assert d["limit"] <= 500 assert d["limit"] <= 500
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
def _wijzig(client, csrf, extras, poort=9200):
return client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": poort},
"connect_apps": False, "extras": extras},
headers={"X-CSRF-Token": csrf})
def test_datamap_en_apparaat_belanden_in_de_compose(client, env, monkeypatch):
"""Een mediamap of USB-stick toevoegen kon alleen via 'compose bewerken',
en dat werd bij de eerstvolgende wijziging overschreven."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
r = _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
"container": "/media", "ro": True}],
"devices": [{"service": "keuzeapp", "host": "/dev/ttyUSB0"}]})
assert r.status_code == 200, r.get_json()
d = _wacht_op_job(client, r.get_json()["job_id"])
assert d.get("status") == "done", d.get("lines")
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
assert "/srv/media:/media:ro" in svc["volumes"]
assert "/dev/ttyUSB0:/dev/ttyUSB0" in svc["devices"]
# En ze staan in de metadata, dus een volgende wijziging behoudt ze.
meta = json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
assert meta["extras"]["volumes"][0]["host"] == "/srv/media"
def test_extras_overleven_een_volgende_wijziging(client, env, monkeypatch):
"""Het renderen begint elke keer opnieuw uit het sjabloon; zonder bewaarde
extra's zou je map er na één instellingswijziging weer af zijn."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
for naam in ("validate_compose",):
monkeypatch.setattr(app_module.docker, naam, lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
_wacht_op_job(client, _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
"container": "/media"}]}).get_json()["job_id"])
# Nu een wijziging zonder extras mee te sturen: de bewaarde keuze telt.
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9201},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
assert "/srv/media:/media" in svc["volumes"]
def test_gevaarlijk_pad_wordt_geweigerd_met_uitleg(client, env):
"""Niet stil corrigeren: wie /etc intikt hoort te horen waarom dat niet gaat."""
csrf = login(client)
_installeer_keuzeapp(client, csrf)
r = _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/var/run/docker.sock",
"container": "/var/run/docker.sock"}]})
assert r.status_code == 400
assert "Docker-socket" in r.get_json()["msg"]
r = _wijzig(client, csrf, {
"devices": [{"service": "keuzeapp", "host": "/etc/shadow"}]})
assert r.status_code == 400
assert "/dev/" in r.get_json()["msg"]
def test_apparatenlijst_is_op_te_vragen(client, env):
login(client)
d = client.get("/api/devices").get_json()
assert "devices" in d and isinstance(d["devices"], list)
for dev in d["devices"]:
assert dev["path"].startswith("/dev/")