From 8b29553a71c87e0eb90c5ef853549bcc869887a9 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 26 Jul 2026 14:51:32 +0200 Subject: [PATCH] v0.5.00-beta - containerbeheer, eigen IP-adressen, veiliger installeren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beheer per container: de stackkaart klapt uit naar de losse containers, met start/stop/herstart, logs en live CPU-/geheugengebruik per container. De containernaam wordt getoetst aan compose_ps van díé stack, zodat de route geen willekeurige container op de host kan raken. - Stacks met een eigen IP-adres (macvlan/ipvlan): netwerkbeheer onder Instellingen → Netwerken, netwerkkeuze in de installatiemodal met voorstel voor het eerstvolgende vrije adres, en automatische omzetting van het gerenderde compose-bestand (poortmappings eruit, ipv4_address erin). De templates in apps/ blijven ongewijzigd. - Toegekende IP's worden vastgehouden in .serverup.json en getoond op de stackkaart, zodat een volgende installatie ze niet opnieuw uitdeelt. - Vrije poort voorstellen bij installeren: next_free_port() bestond al maar werd nergens gebruikt. Bezette poorten worden in de UI gemeld. - Genereerknop voor velden die op een geheim wijzen (token/password/secret), lokaal gegenereerd via crypto.getRandomValues. - Compose valideren met `docker compose config` vóór het wegschrijven, zowel bij de editor als na het renderen bij installatie. Ontbreekt de compose-CLI, dan blokkeert dat een installatie niet. - Uitloggen in de zijbalk; gebruikersbeheer en wachtwoord wijzigen onder Instellingen → Beveiliging. - docs/netwerken.md (incl. de shim-interface die de host nodig heeft om zijn eigen macvlan-containers te bereiken) en docs/beveiliging.md toegevoegd. - CHANGELOG bijgewerkt; testsuite uitgebreid naar 112 tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- CHANGELOG.md | 87 +++++++++ docs/beveiliging.md | 111 ++++++++++++ docs/netwerken.md | 134 ++++++++++++++ server-up/app.py | 268 ++++++++++++++++++++++++++-- server-up/core/compose_transform.py | 87 +++++++++ server-up/core/docker.py | 81 +++++++++ server-up/core/networks.py | 181 +++++++++++++++++++ server-up/templates/index.html | 265 ++++++++++++++++++++++++++- tests/test_compose_transform.py | 86 +++++++++ tests/test_networks.py | 77 ++++++++ tests/test_stack_api.py | 82 +++++++++ 11 files changed, 1445 insertions(+), 14 deletions(-) create mode 100644 docs/beveiliging.md create mode 100644 docs/netwerken.md create mode 100644 server-up/core/compose_transform.py create mode 100644 server-up/core/networks.py create mode 100644 tests/test_compose_transform.py create mode 100644 tests/test_networks.py create mode 100644 tests/test_stack_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7bad3f..783c3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,90 @@ +# v0.5.00-beta — Authenticatie, beveiliging en eigen IP-adressen + +> **Let op bij het bijwerken.** Server Up heeft nu een login. Open na het +> bijwerken meteen de webinterface en maak een beheerdersaccount aan — zolang +> dat niet gebeurd is, kan iedereen die de pagina bereikt het account claimen. +> De poort wordt voortaan standaard op `127.0.0.1` gebonden; zet `BIND=0.0.0.0` +> in je `.env` als je er van buiten de server bij moet (liefst achter een +> reverse proxy met TLS — zie `docs/beveiliging.md`). + +## 🔐 Authenticatie + +Tot nu toe was elke `/api/*`-route open. Omdat Server Up de docker-socket als +root gebruikt, betekende dat: wie de poort kon bereiken, had root op de host. + +- Lokale accounts met scrypt-gehashte wachtwoorden, sessiecookie + (`HttpOnly`, `SameSite=Strict`) en lockout na vijf mislukte pogingen. +- Optionele SSO via een reverse-proxy-header (Authelia/Authentik/Cloudflare + Access). De header wordt alleen vertrouwd vanaf een geconfigureerd proxy-IP. +- Loginscherm en eerste-account-setup in de interface; gebruikersbeheer en + wachtwoord wijzigen onder Instellingen → Beveiliging. + +## 🛡️ Beveiligingsfixes + +- **CSRF**: elke mutatie vereist een `X-CSRF-Token`-header. Routes die de + toestand wijzigen accepteren geen `GET` meer — `/api/docker/restart` was + eerder met een ``-tag vanaf een willekeurige website te triggeren. +- **Path traversal**: `/api/store/install` controleerde de instantienaam niet; + `"instance": "../../…"` schreef buiten de library. Alle stack-, instantie- en + repo-namen lopen nu door één `safe_name()`-validatie. +- **Tokenlek**: git-tokens werden teruggegeven door `/api/repos` en + `/api/settings`. Die zijn vervangen door een `has_token`-vlag; opslaan met een + leeg veld wist het bestaande token niet meer. +- **Git-URL's**: alleen `http(s)://`, `ssh://` en `git@host:pad`. Git's + `ext::`-transport voert een shell-commando uit en wordt nu geweigerd. +- **Templates** renderen in een Jinja2-sandbox (server-side template injection). +- **SSH**: host-keys worden geverifieerd (`accept-new` + `/data/known_hosts`); + eerder stond `StrictHostKeyChecking=no`, waarmee elke MITM onzichtbaar was. +- **Modules**: repo's worden niet meer automatisch bij elke start gepulld + (`AUTO_SYNC_ON_BOOT`, standaard uit) en kunnen per repo op een commit worden + vastgezet. +- Productie-WSGI-server (waitress) i.p.v. de Flask-ontwikkelserver, limiet op + request-grootte, `ProxyFix`, en CSP/`X-Frame-Options`/`nosniff`/ + `Referrer-Policy`-headers. +- Tailwind, Alpine en htmx worden meegeleverd in plaats van vanaf een CDN + geladen; Google Fonts is eruit. De interface werkt nu ook offline. +- `config.json` en de sleutel staan op 0600. + +## 🌐 Stacks met een eigen IP-adres + +Nieuw: geef een stack een eigen adres in je LAN in plaats van poorten op de host +(macvlan/ipvlan). Geen poortconflicten meer, apps op hun eigen standaardpoort, en +je kunt per app firewallen. + +- Netwerkbeheer onder Instellingen → Netwerken (driver, host-interface, subnet, + gateway, optionele IP-range). +- Bij het installeren kies je "Poorten op de host" of een netwerk; Server Up + stelt het eerstvolgende vrije adres voor en houdt toegekende adressen vast. +- Het gerenderde compose-bestand wordt automatisch omgezet: poortmappings eruit, + netwerk met `ipv4_address` erin. De templates in `apps/` blijven ongewijzigd. +- Uitleg en valkuilen (waaronder de shim-interface die de host nodig heeft om + zijn eigen macvlan-containers te bereiken) staan in `docs/netwerken.md`. + +## 🧩 Beheer per container + +De stackkaart klapt uit naar de losse containers: per container starten, stoppen, +herstarten, logs bekijken en live CPU-/geheugengebruik. + +## 📦 Veiliger installeren + +- Poortvelden krijgen een vrij poortnummer voorgesteld — `next_free_port()` + bestond al maar werd nergens gebruikt. Bezette poorten worden gemeld. +- Velden voor tokens en wachtwoorden krijgen een genereerknop. +- Compose wordt gevalideerd (`docker compose config`) vóór het wegschrijven, dus + een typefout in de editor maakt een draaiende stack niet meer onstartbaar. + +## 🔧 Overig + +- Testsuite met pytest (112 tests), ook als stap in beide deploy-workflows. +- Audit-log gebruikt één gedeelde SQLite-verbinding — elke job lekte eerder een + file descriptor. Joblogs worden afgekapt op 2000 regels. +- Het `VERSION`-bestand is de enige bron voor het versienummer. +- Lichte `/healthz` voor de healthcheck in plaats van `docker info`. +- `fix-config.sh` verwijderd: bevatte een hardgecodeerd intern IP en + overschreef de configuratie van de gebruiker. + +--- + # v0.4.60 — Tweecijferig patch-nummer ## Versiebeleid vanaf v0.4.60 diff --git a/docs/beveiliging.md b/docs/beveiliging.md new file mode 100644 index 0000000..f94760f --- /dev/null +++ b/docs/beveiliging.md @@ -0,0 +1,111 @@ +# Beveiliging + +Server Up beheert de Docker-daemon. Wie toegang heeft tot de webinterface, kan +containers starten met willekeurige volumes — en daarmee in de praktijk alles op +de host. Behandel toegang tot Server Up dus als root-toegang tot de server. + +Vanaf v0.5.00 is de interface standaard afgeschermd met een login. + +--- + +## Eerste start + +Bij de eerste start is er nog geen account. Open de webinterface en maak er +meteen een aan: **zolang dat niet gebeurd is, kan iedereen die de pagina bereikt +het beheerdersaccount claimen.** De container laat daarom bij het opstarten een +waarschuwing zien. + +Standaard bindt `docker-compose.yml` de poort op `127.0.0.1`, dus alleen vanaf de +server zelf bereikbaar. Wil je er van buitenaf bij, zet dan een reverse proxy met +TLS ervoor (zie onder) en pas `BIND` aan in je `.env`. + +## Authenticatiemodi + +Instelbaar via `PUT /api/auth/mode`: + +| Modus | Betekenis | +|-------|-----------| +| `local` (standaard) | Gebruikersnaam + wachtwoord in Server Up zelf | +| `proxy` | Identiteit komt uit een header van je reverse proxy; geen lokale login meer | +| `both` | Beide; handig om SSO te testen zonder jezelf buiten te sluiten | + +Wachtwoorden worden opgeslagen als scrypt-hash (n=2¹⁴) met een willekeurige salt. +Na vijf mislukte pogingen is het account vijf minuten geblokkeerd. + +### SSO via een reverse proxy + +Draai je Authelia, Authentik of Cloudflare Access, dan kan die de identiteit in +een header zetten (meestal `Remote-User`). + +Belangrijk: die header wordt **alleen** vertrouwd als het bron-IP in +`trusted_proxies` staat. Zonder die lijst kan iedereen de header zelf meesturen +en zich voordoen als beheerder. Server Up weigert daarom modus `proxy`/`both` als +`trusted_proxies` leeg is. + +Zorg er ook voor dat je proxy de header van binnenkomende requests **wist** en +zelf opnieuw zet. + +## Reverse proxy met TLS + +Server Up spreekt gewoon HTTP. Zet er een proxy voor die TLS afhandelt: + +```nginx +location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Remote-User ""; # wis wat de client stuurt +} +``` + +Zet `SU_HTTPS=1` in de omgeving zodra alles via TLS loopt; de sessiecookie krijgt +dan de `Secure`-vlag. + +## Wat er beveiligd is + +- **Sessies** — `HttpOnly`, `SameSite=Strict`, 12 uur geldig. +- **CSRF** — elke POST/PUT/DELETE vereist de `X-CSRF-Token`-header. Routes die de + toestand wijzigen accepteren geen GET meer. +- **Padvalidatie** — stack-, instantie- en repo-namen worden gecontroleerd, zodat + ze nooit buiten `LIBRARY_DIR` of de git-cache kunnen wijzen. +- **Git-URL's** — alleen `http(s)://`, `ssh://` en `git@host:pad`. Git's + `ext::`-transport voert een shell-commando uit en wordt geweigerd. +- **Templates** — boilerplates renderen in een Jinja2-sandbox. +- **Geheimen** — git-tokens worden nooit teruggegeven door de API (alleen een + `has_token`-vlag); `config.json` en `secret.key` staan op 0600. +- **Headers** — CSP op `'self'`, `X-Frame-Options: DENY`, `nosniff`, + `Referrer-Policy: no-referrer`. Alle front-end libraries worden meegeleverd, + dus er gaat op runtime niets naar een CDN. +- **SSH** — host-keys worden geverifieerd (`accept-new`, opgeslagen in + `/data/known_hosts`). + +## Externe repo's en modules + +Een module is Python-code die Server Up **uitvoert**. Voeg alleen repo's toe die +je vertrouwt. Twee knoppen om aan te draaien: + +- `AUTO_SYNC_ON_BOOT` staat standaard **uit**. Repo's worden dus niet vanzelf + bijgewerkt; je synchroniseert zelf wanneer je dat wilt. +- Zet per repo een `commit` in de config om hem op een specifieke commit vast te + zetten: + + ```json + {"id": "mijn-apps", "url": "https://…", "branch": "main", "commit": "a1b2c3d"} + ``` + +## Wat níét is afgedekt + +- **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat + is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints + toelaat werkt niet, omdat compose vrijwel alles nodig heeft. +- **Rechten per gebruiker.** Elk account heeft dezelfde volledige toegang; er + zijn geen rollen. +- **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een + proxy met rate limiting voor. + +## Een probleem melden + +Vind je een beveiligingsprobleem, meld het dan via de repository-issues met zo +veel mogelijk details over de reproductie. diff --git a/docs/netwerken.md b/docs/netwerken.md new file mode 100644 index 0000000..a314c9e --- /dev/null +++ b/docs/netwerken.md @@ -0,0 +1,134 @@ +# Stacks een eigen IP-adres geven (macvlan / ipvlan) + +Standaard publiceert een stack poorten op de host: Vaultwarden op `8222`, Mealie +op `9925`, enzovoort. Dat werkt, maar levert op den duur een lange lijst +poortnummers op die je moet onthouden, en twee apps die allebei poort 8080 willen +gaan niet samen. + +Met een **macvlan**- of **ipvlan**-netwerk krijgt een stack een eigen IP-adres in +je LAN. De app draait dan gewoon op zijn eigen standaardpoort: + +| Zonder | Met eigen IP | +|--------|--------------| +| `http://192.168.1.10:8222` | `http://192.168.1.240` | +| `http://192.168.1.10:9925` | `http://192.168.1.241` | + +Je kunt er ook per app op firewallen, en een DNS-naam aan koppelen. + +--- + +## ⚠️ Lees dit eerst: de host bereikt zijn eigen containers niet + +Dit is de bekendste valkuil van macvlan. De Docker-host kan **niet** bij een +container die op een macvlan-netwerk draait, en andersom ook niet. Alle andere +apparaten in je netwerk kunnen dat wél. + +Draait Server Up op dezelfde machine als de containers, dan zie je dus: + +- vanaf je laptop: `http://192.168.1.240` werkt +- vanaf de server zelf: `curl http://192.168.1.240` loopt vast + +Heb je dat nodig (bijvoorbeeld voor een healthcheck of een reverse proxy die op +de host draait), maak dan een shim-interface aan op de host: + +```bash +# Eenmalig; vervang eth0 en de adressen door die van jouw netwerk. +ip link add shim link eth0 type macvlan mode bridge +ip addr add 192.168.1.239/32 dev shim +ip link set shim up +ip route add 192.168.1.240/28 dev shim +``` + +Zet dat in een systemd-unit of in `/etc/network/interfaces`, anders is het na een +herstart weg. + +**ipvlan (l2)** heeft dit probleem niet op dezelfde manier, maar vereist wel dat +je switch en router er goed mee omgaan. Werkt macvlan niet, probeer dan ipvlan. + +--- + +## Stap 1 — Bepaal je netwerkgegevens + +Je hebt vier dingen nodig: + +| Wat | Voorbeeld | Hoe kom je eraan | +|-----|-----------|------------------| +| Host-interface | `eth0` | `ip -br link` op de server | +| Subnet | `192.168.1.0/24` | Je router; hetzelfde subnet als de server | +| Gateway | `192.168.1.1` | `ip route \| grep default` | +| IP-range | `192.168.1.240/28` | Een blok dat je **buiten je DHCP-bereik** houdt | + +De IP-range is optioneel maar sterk aanbevolen. Zonder range mag Docker het hele +subnet gebruiken en kan het een adres uitdelen dat je DHCP-server ook aan een +telefoon geeft. `192.168.1.240/28` reserveert `.240` t/m `.255` — zestien +adressen. Zet dat bereik in je router buiten de DHCP-pool. + +## Stap 2 — Netwerk aanmaken in Server Up + +Ga naar **Instellingen → Netwerken → Netwerk** en vul de gegevens in. Server Up +maakt het Docker-netwerk aan en onthoudt de definitie. + +Hetzelfde met de hand zou zijn: + +```bash +docker network create -d macvlan \ + --subnet 192.168.1.0/24 \ + --gateway 192.168.1.1 \ + --ip-range 192.168.1.240/28 \ + -o parent=eth0 \ + lan +``` + +## Stap 3 — Een stack installeren op een eigen IP + +Kies in de installatiemodal bij **Netwerk** je netwerk in plaats van "Poorten op +de host". Server Up stelt het eerstvolgende vrije adres voor; je kunt het +overschrijven. + +Bij het installeren gebeurt er dit met het compose-bestand: + +```yaml +# vóór # ná +services: services: + vaultwarden: vaultwarden: + image: vaultwarden/server image: vaultwarden/server + ports: networks: + - "8222:80" lan: + ipv4_address: 192.168.1.240 + networks: + lan: + external: true +``` + +De poortmapping verdwijnt — die heeft geen functie meer. Vaultwarden luistert op +poort 80 en is bereikbaar op `http://192.168.1.240`. + +Bestaat een stack uit meerdere containers, dan krijgt de container die poorten +publiceerde het vaste adres; de rest komt zonder vast adres in hetzelfde netwerk, +zodat ze elkaar op servicenaam blijven vinden. + +--- + +## Adresbeheer + +Server Up bewaart het toegekende adres in `.serverup.json` in de stackmap. Zo +blijft het gereserveerd, ook als de stack gestopt is, en krijgt een volgende +installatie het volgende vrije adres voorgesteld. + +Wil je het adres van een bestaande stack wijzigen, pas dan het compose-bestand +aan via **compose bewerken** en werk `ipv4_address` bij. + +## Verwijderen + +Een netwerk kan pas weg als er geen containers meer op draaien. Stop eerst de +betreffende stacks; Server Up weigert het anders met een melding. + +## Problemen oplossen + +| Symptoom | Oorzaak | +|----------|---------| +| `network ... not found` bij het starten | Netwerk verwijderd terwijl de stack er nog naar verwijst — maak het opnieuw aan | +| Container krijgt geen verbinding | Verkeerde `parent`-interface, of de interface zit in een bond/bridge | +| Adres al in gebruik | Het adres valt binnen je DHCP-bereik; verklein de DHCP-pool of kies een andere range | +| Vanaf de server niet bereikbaar, vanaf laptop wel | Verwacht gedrag — zie de shim-interface bovenaan | +| Werkt niet op WiFi | Klopt: macvlan werkt niet over een draadloze interface | diff --git a/server-up/app.py b/server-up/app.py index 862ba0b..6418405 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -15,6 +15,7 @@ from flask import Flask, Response, jsonify, render_template, request, session import core as cfg from core import audit, jobs, i18n from core import auth, docker, git, boilerplates, updater +from core import compose_transform, networks from core.modules import Module, CORE, discover app = Flask(__name__, static_folder="static", template_folder="templates") @@ -391,16 +392,20 @@ def api_stacks(): except Exception: pass - def _stack_logo(d): - # 1) Voorkeur: opgeslagen metadata bij installatie + def _stack_meta(d) -> dict: mf = d / ".serverup.json" if mf.exists(): try: - m = json.loads(mf.read_text("utf-8")) - if m.get("logo_url") or m.get("icon"): - return m.get("logo_url", ""), m.get("icon", "") + return json.loads(mf.read_text("utf-8")) except Exception: pass + return {} + + def _stack_logo(d): + # 1) Voorkeur: opgeslagen metadata bij installatie + m = _stack_meta(d) + if m.get("logo_url") or m.get("icon"): + return m.get("logo_url", ""), m.get("icon", "") # 2) Fallback: match op naam met de store (ook voor oudere installaties) if d.name in icon_map: h = icon_map[d.name]; return h["logo_url"], h["icon"] @@ -417,11 +422,13 @@ def api_stacks(): continue ct = docker.compose_ps(d, name=d.name) logo_url, icon = _stack_logo(d) + meta = _stack_meta(d) out.append({ "name": d.name, "path": str(d), "has_compose": True, "running": any(c["running"] for c in ct), "containers": ct, "logo_url": logo_url, "icon": icon, + "network": meta.get("network", ""), "ip": meta.get("ip", ""), }) return jsonify(out) @@ -536,10 +543,66 @@ def api_stack_compose_put(name): lib, d = _stack_dir(name) if not d: return jsonify(ok=False, msg="ongeldige naam"), 400 - docker.write_compose(d, (request.json or {}).get("content", "")) + content = (request.json or {}).get("content", "") + # Eerst valideren, dan pas schrijven: een typefout maakt anders een + # draaiende stack onstartbaar zonder dat je het merkt. + ok, err = docker.validate_compose_text(content, d) + if not ok: + return jsonify(ok=False, msg=f"Compose is ongeldig:\n{err}"), 400 + docker.write_compose(d, content) + audit.log("stacks", "compose_edit", "ok", ref=name, ip=request.remote_addr) return jsonify(ok=True) +# ── Containers binnen een stack ────────────────────────────────────────────── + +def _stack_containers(name: str) -> list[str]: + """Namen van de containers die bij deze stack horen.""" + lib, d = _stack_dir(name) + if not d or not d.exists(): + return [] + return [c["name"] for c in docker.compose_ps(d, name=name) if c.get("name")] + + +@app.route("/api/stacks//containers") +def api_stack_containers(name): + lib, d = _stack_dir(name) + if not d: + return jsonify(ok=False, msg="ongeldige naam"), 400 + if not d.exists(): + return jsonify(ok=False, msg="niet gevonden"), 404 + containers = docker.compose_ps(d, name=name) + live = docker.stats([c["name"] for c in containers if c.get("running")]) + for c in containers: + c["stats"] = live.get(c["name"], {}) + return jsonify(ok=True, containers=containers) + + +@app.route("/api/stacks//containers//", methods=["POST"]) +def api_container_action(name, container, action): + """Start/stop/herstart één container binnen een stack. + + De containernaam wordt gecontroleerd tegen de containers die bij déze stack + horen; zo kan deze route nooit een willekeurige container op de host raken. + """ + if action not in ("start", "stop", "restart"): + return jsonify(ok=False, msg="onbekende actie"), 400 + if container not in _stack_containers(name): + return jsonify(ok=False, msg="container hoort niet bij deze stack"), 404 + ok, msg = docker.container_action(container, action) + audit.log("containers", action, "ok" if ok else "error", ref=container, + ip=request.remote_addr) + return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt")) + + +@app.route("/api/stacks//containers//logs") +def api_container_logs(name, container): + if container not in _stack_containers(name): + return jsonify(ok=False, msg="container hoort niet bij deze stack"), 404 + tail = min(max(int(request.args.get("tail", 200)), 1), 2000) + return jsonify(ok=True, logs=docker.container_logs(container, tail)) + + @app.route("/api/stacks//logs") def api_stack_logs(name): lib, d = _stack_dir(name) @@ -664,6 +727,47 @@ def _find_stack_src(stack: str, rid: str) -> Path | None: return None +_SECRET_HINTS = ("token", "password", "passwd", "secret", "apikey", "api_key", + "_key", "salt", "pepper") + + +def _is_secret_field(f: dict) -> bool: + haystack = f"{f.get('name', '')} {f.get('title', '')}".lower() + return any(h in haystack for h in _SECRET_HINTS) + + +def _annotate_fields(fields: list[dict]) -> list[dict]: + """Verrijk de installatievelden met een vrije poort en een secret-vlag. + + - poortvelden krijgen een default die nog niet in gebruik is, zodat een + installatie niet strandt op 'port is already allocated'; + - geheime velden worden gemarkeerd zodat de UI een genereerknop toont. + """ + try: + bezet = docker.used_ports() + except Exception: + bezet = set() + uitgedeeld: set[int] = set() + for f in fields: + if _is_secret_field(f): + f["secret"] = True + naam = (f.get("name") or "").lower() + if f.get("type") == "int" and "port" in naam: + try: + gewenst = int(f.get("default") or 0) + except (TypeError, ValueError): + gewenst = 0 + kandidaat = gewenst if gewenst > 0 else 8100 + while kandidaat in bezet or kandidaat in uitgedeeld: + kandidaat += 1 + uitgedeeld.add(kandidaat) + f["suggested"] = kandidaat + if kandidaat != gewenst: + f["port_taken"] = gewenst + f["default"] = kandidaat + return fields + + @app.route("/api/store/preview", methods=["POST"]) def api_store_preview(): """Preview een stack: lees compose + .env en extract configureerbare velden. @@ -681,7 +785,7 @@ def api_store_preview(): # ── Boilerplate (ChristianLempa-formaat) ────────────────────────────────── if boilerplates.is_boilerplate(src): meta = boilerplates.metadata(src) - flds = boilerplates.fields(src) + flds = _annotate_fields(boilerplates.fields(src)) # Render een preview met defaults, zodat de gebruiker ziet wat er komt try: ctx = boilerplates.build_context(src, {}) @@ -724,16 +828,25 @@ def api_store_preview(): k, v = k.strip(), v.strip() is_port = "PORT" in k.upper() is_tag = k.upper() in ("TAG", "VERSION", "IMAGE_TAG", "IMAGE_VERSION") or k.upper().endswith("_TAG") or k.upper().endswith("_VERSION") - result["env_vars"].append({ + entry = { "key": k, "value": v, "is_port": is_port, "is_tag": is_tag, + "secret": _is_secret_field({"name": k, "title": k}), "desc": "Poort" if is_port else "Image tag" if is_tag else "", - }) + } if is_port: try: - result["ports"].append({"key": k, "value": int(v)}) + poort = int(v) except ValueError: result["ports"].append({"key": k, "value": v}) + else: + vrij = docker.next_free_port(poort) + entry["suggested"] = vrij + if vrij != poort: + entry["port_taken"] = poort + entry["value"] = str(vrij) + result["ports"].append({"key": k, "value": vrij}) + result["env_vars"].append(entry) # Parse compose voor images cf = docker.find_compose(src) @@ -787,8 +900,24 @@ def api_store_install(): env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"} image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"} bp_values = d.get("values", {}) # boilerplate variable values + net_name = (d.get("network") or "").strip() # leeg = poorten op de host + net_ip = (d.get("ip") or "").strip() if not stack: return jsonify(ok=False, msg="stack vereist") + + net = None + if net_name: + net = _network_by_name(net_name) + if not net: + return jsonify(ok=False, msg=f"Netwerk '{net_name}' is niet geconfigureerd"), 400 + if not net_ip: + net_ip = networks.next_free_ip(net, _reserved_ips()) + if not net_ip: + return jsonify(ok=False, msg="Geen vrij IP-adres beschikbaar in dit netwerk"), 400 + if not networks.ip_in_network(net_ip, net): + return jsonify(ok=False, msg=f"{net_ip} ligt buiten het subnet van '{net_name}'"), 400 + if net_ip in _reserved_ips() or net_ip in networks.used_ips(net_name): + return jsonify(ok=False, msg=f"{net_ip} is al in gebruik"), 409 # De instantienaam bepaalt de doelmap én de compose-projectnaam. Zonder # controle schrijft `"instance": "../../etc"` buiten de library. if not cfg.safe_name(inst): @@ -897,6 +1026,30 @@ def api_store_install(): cf.write_text(new_content) jobs.log(qq, "ok", "Image tags aangepast") + # Eigen IP-adres in plaats van poorten op de host + if net: + cf = docker.find_compose(dest) + if not cf: + jobs.log(qq, "error", "Geen compose-bestand om aan te passen") + jobs.finish(jid, "error"); jobs.done(qq); return + try: + nieuw = compose_transform.to_dedicated_ip( + cf.read_text(encoding="utf-8"), net_name, net_ip) + except compose_transform.TransformError as e: + jobs.log(qq, "error", f"Netwerkomzetting mislukt: {e}") + jobs.finish(jid, "error"); jobs.done(qq); return + cf.write_text(nieuw, encoding="utf-8") + jobs.log(qq, "ok", f"Eigen IP-adres: {net_ip} op netwerk '{net_name}'") + jobs.log(qq, "dim", "Poortmappings verwijderd — de app draait op " + "zijn eigen poort op dit adres.") + + # 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) + if not ok_cfg: + jobs.log(qq, "error", f"Compose is ongeldig:\n{err_cfg}") + jobs.finish(jid, "error"); jobs.done(qq); return + # Data dir try: Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True) @@ -915,6 +1068,10 @@ def api_store_install(): "name": meta.get("name", stack), "logo_url": logo, "icon": meta.get("icon", ""), + # Reserveert het adres: zolang deze stack bestaat krijgt + # geen andere installatie hetzelfde IP voorgesteld. + "network": net_name, + "ip": net_ip if net else "", }, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass @@ -939,6 +1096,97 @@ def api_store_install(): return jsonify(ok=True, job_id=jid) +# ── Netwerken (eigen IP-adres per stack) ───────────────────────────────────── + +def _network_by_name(name: str) -> dict | None: + for n in cfg.load().get("NETWORKS", []) or []: + if isinstance(n, dict) and n.get("name") == name: + return n + return None + + +def _reserved_ips(exclude_stack: str = "") -> set[str]: + """IP's die Server Up bij installatie aan een stack heeft toegekend. + + Staat los van used_ips(): een gestopte stack houdt zijn adres, zodat een + nieuwe installatie het niet afpakt. + """ + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + ips: set[str] = set() + if not lib.exists(): + return ips + for d in lib.iterdir(): + if not d.is_dir() or d.name == exclude_stack: + continue + mf = d / ".serverup.json" + if not mf.exists(): + continue + try: + meta = json.loads(mf.read_text("utf-8")) + except Exception: + continue + if meta.get("ip"): + ips.add(meta["ip"]) + return ips + + +@app.route("/api/networks") +def api_networks(): + defined = cfg.load().get("NETWORKS", []) or [] + live = {n["name"]: n for n in networks.list_docker_networks()} + out = [] + for n in defined: + if not isinstance(n, dict): + continue + naam = n.get("name", "") + entry = {**n, "created": naam in live, + "used_ips": sorted(networks.used_ips(naam)) if naam in live else []} + entry["next_ip"] = networks.next_free_ip(n, _reserved_ips()) + out.append(entry) + return jsonify(networks=out, interfaces=networks.host_interfaces(), + drivers=list(networks.DRIVERS)) + + +@app.route("/api/networks", methods=["POST"]) +def api_networks_add(): + d = request.json or {} + net = {"name": (d.get("name") or "").strip(), + "driver": d.get("driver") or "macvlan", + "parent": (d.get("parent") or "").strip(), + "subnet": (d.get("subnet") or "").strip(), + "gateway": (d.get("gateway") or "").strip(), + "ip_range": (d.get("ip_range") or "").strip()} + probleem = networks.validate(net) + if probleem: + return jsonify(ok=False, msg=probleem), 400 + if _network_by_name(net["name"]): + return jsonify(ok=False, msg="Er bestaat al een netwerk met deze naam"), 409 + ok, msg = networks.create(net) + if not ok: + return jsonify(ok=False, msg=msg), 400 + cfg.patch({"NETWORKS": list(cfg.load().get("NETWORKS", []) or []) + [net]}) + audit.log("networks", "add", "ok", ref=net["name"], ip=request.remote_addr) + return jsonify(ok=True, msg=msg) + + +@app.route("/api/networks/", methods=["DELETE"]) +def api_networks_del(name): + net = _network_by_name(name) + if not net: + return jsonify(ok=False, msg="Netwerk niet gevonden"), 404 + in_gebruik = sorted(networks.used_ips(name)) + if in_gebruik: + return jsonify(ok=False, + msg=f"Nog {len(in_gebruik)} container(s) op dit netwerk — " + "stop die stacks eerst."), 409 + ok, msg = networks.remove(name) + cfg.patch({"NETWORKS": [n for n in cfg.load().get("NETWORKS", []) or [] + if n.get("name") != name]}) + audit.log("networks", "delete", "ok" if ok else "error", ref=name, + ip=request.remote_addr) + return jsonify(ok=True, msg=msg if ok else f"Uit config verwijderd ({msg})") + + # ── Module Store ────────────────────────────────────────────────────────────── @app.route("/api/modules") diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py new file mode 100644 index 0000000..c211cbc --- /dev/null +++ b/server-up/core/compose_transform.py @@ -0,0 +1,87 @@ +"""Compose-transformatie: gepubliceerde poorten → een eigen IP-adres. + +Wordt toegepast op het al gerenderde compose-bestand. Daardoor werkt het voor +zowel het boilerplate-formaat als het klassieke Server Up-formaat, en hoeven de +templates in apps/ niet aangepast te worden. + +Voorbeeld: + + services: services: + web: web: + image: nginx image: nginx + ports: → networks: + - "8080:80" lan: + ipv4_address: 192.168.1.240 + networks: + lan: + external: true +""" +from __future__ import annotations + +import yaml + + +class TransformError(Exception): + pass + + +def to_dedicated_ip(compose_text: str, network: str, ip: str, + only_service: str | None = None) -> str: + """Geef het compose-bestand terug met poortmappings vervangen door een IP. + + Het IP wordt toegekend aan één service: `only_service` als die is opgegeven, + anders de eerste service die poorten publiceert (en anders de eerste service + überhaupt). Overige services komen zonder vast adres in hetzelfde netwerk, + zodat ze elkaar op servicenaam kunnen blijven vinden. + """ + try: + doc = yaml.safe_load(compose_text) + except yaml.YAMLError as e: + raise TransformError(f"compose is geen geldige YAML: {e}") from e + if not isinstance(doc, dict): + raise TransformError("compose bevat geen mapping op het hoogste niveau") + + services = doc.get("services") + if not isinstance(services, dict) or not services: + raise TransformError("compose bevat geen services") + + doel = only_service + if doel is None: + doel = next((naam for naam, svc in services.items() + if isinstance(svc, dict) and svc.get("ports")), None) + if doel is None: + doel = next(iter(services)) + if doel not in services: + raise TransformError(f"service '{doel}' bestaat niet in dit compose-bestand") + + for naam, svc in services.items(): + if not isinstance(svc, dict): + continue + # Poorten publiceren heeft geen zin meer — de container is direct + # bereikbaar op zijn eigen adres. + svc.pop("ports", None) + if naam == doel: + svc["networks"] = {network: {"ipv4_address": ip}} + else: + svc["networks"] = [network] + + # Het netwerk wordt door Server Up beheerd, niet door deze compose-stack. + doc["networks"] = {network: {"external": True}} + + return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False, + allow_unicode=True, width=120) + + +def published_ports(compose_text: str) -> list[str]: + """Alle host-poorten die dit compose-bestand publiceert (voor de UI).""" + try: + doc = yaml.safe_load(compose_text) or {} + except yaml.YAMLError: + return [] + out = [] + for svc in (doc.get("services") or {}).values(): + if not isinstance(svc, dict): + continue + for p in svc.get("ports") or []: + out.append(str(p)) + return out diff --git a/server-up/core/docker.py b/server-up/core/docker.py index bcedd48..588353d 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -137,6 +137,41 @@ def restart_container(name: str) -> tuple[bool, str]: return r.returncode == 0, (r.stderr or r.stdout).strip() +def container_action(name: str, action: str) -> tuple[bool, str]: + """Start, stop of herstart één container. + + De aanroeper is verantwoordelijk voor het controleren dát deze container bij + een door Server Up beheerde stack hoort (zie app.api_container_action). + """ + if action not in ("start", "stop", "restart"): + return False, "onbekende actie" + r = _run([_bin(), action, name], timeout=60) + return r.returncode == 0, (r.stderr or r.stdout).strip() + + +def container_logs(name: str, tail=200) -> str: + r = _run([_bin(), "logs", "--tail", str(int(tail)), name], timeout=15) + return r.stdout or r.stderr + + +def stats(names: list[str]) -> dict[str, dict]: + """Momentopname van CPU/geheugen per container: {naam: {cpu, mem, ...}}.""" + if not names: + return {} + fmt = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}" + r = _run([_bin(), "stats", "--no-stream", "--format", fmt] + list(names), timeout=25) + if r.returncode != 0: + return {} + out = {} + for line in r.stdout.strip().splitlines(): + p = line.split("\t") + if len(p) >= 4: + out[p[0]] = {"cpu": p[1], "mem": p[2], "mem_pct": p[3], + "net": p[4] if len(p) > 4 else "", + "block": p[5] if len(p) > 5 else ""} + return out + + # ── Compose helpers ─────────────────────────────────────────────────────────── def find_compose(d: Path) -> Path | None: @@ -244,6 +279,52 @@ def write_compose(d: Path, content: str): f.write_text(content) +def validate_compose(d: Path) -> tuple[bool, str]: + """Controleer een compose-bestand met `docker compose config -q`. + + Zo blijkt een typefout meteen bij het opslaan, in plaats van pas bij de + volgende start — als de stack al stuk is. + """ + f = find_compose(d) + if not f: + return False, "geen compose-bestand gevonden" + r = _run(_get_compose() + ["-f", str(f), "config", "-q"], cwd=str(d), timeout=30) + return _validation_result(r) + + +def _validation_result(r) -> tuple[bool, str]: + if r.returncode == 0: + return True, "" + # Is de compose-CLI zelf niet beschikbaar, dan kunnen we niets zeggen over + # het bestand. Dat mag geen reden zijn om een installatie te blokkeren. + melding = (r.stderr or r.stdout or "").strip() + if r.returncode == 127 or "niet gevonden" in melding or "not found" in melding.lower(): + return True, "" + return False, melding[:800] + + +def validate_compose_text(content: str, d: Path) -> tuple[bool, str]: + """Valideer compose-inhoud zonder het doelbestand te overschrijven. + + Het tijdelijke bestand staat in dezelfde map, zodat relatieve paden en een + eventueel .env-bestand meegenomen worden zoals bij een echte start. + """ + tmp = d / ".serverup-validate.yml" + try: + tmp.write_text(content, encoding="utf-8") + r = _run(_get_compose() + ["-f", str(tmp), "config", "-q"], + cwd=str(d), timeout=30) + ok, msg = _validation_result(r) + return ok, msg.replace(str(tmp), "compose") + except Exception as e: + return False, str(e) + finally: + try: + tmp.unlink(missing_ok=True) + except Exception: + pass + + def used_ports() -> set[int]: r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10) ports = set() diff --git a/server-up/core/networks.py b/server-up/core/networks.py new file mode 100644 index 0000000..9228bbb --- /dev/null +++ b/server-up/core/networks.py @@ -0,0 +1,181 @@ +"""Macvlan/ipvlan-netwerken — stacks een eigen IP-adres in het LAN geven. + +In plaats van poorten publiceren op de host (`8222:80`) krijgt een container een +eigen adres op het fysieke netwerk. Voordelen: geen poortconflicten meer, apps +draaien op hun eigen standaardpoort, en je kunt per app firewallen op IP. + +Belangrijke beperking van macvlan: de host kan zijn eigen macvlan-containers +niet bereiken zonder een extra shim-interface. Zie docs/netwerken.md. +""" +from __future__ import annotations + +import ipaddress +import json +import re + +from core import docker + +_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$") +DRIVERS = ("macvlan", "ipvlan") + + +class NetworkError(Exception): + pass + + +# ── Inspectie ──────────────────────────────────────────────────────────────── + +def list_docker_networks() -> list[dict]: + """Alle macvlan/ipvlan-netwerken die de Docker-daemon kent.""" + r = docker._run([docker._bin(), "network", "ls", "--format", + "{{.Name}}\t{{.Driver}}\t{{.Scope}}"], timeout=15) + if r.returncode != 0: + return [] + out = [] + for line in r.stdout.strip().splitlines(): + p = line.split("\t") + if len(p) >= 2 and p[1] in DRIVERS: + out.append({"name": p[0], "driver": p[1], + "scope": p[2] if len(p) > 2 else ""}) + return out + + +def inspect(name: str) -> dict: + r = docker._run([docker._bin(), "network", "inspect", name], timeout=15) + if r.returncode != 0: + return {} + try: + data = json.loads(r.stdout) + return data[0] if data else {} + except Exception: + return {} + + +def exists(name: str) -> bool: + return bool(inspect(name)) + + +def used_ips(name: str) -> set[str]: + """IP-adressen die binnen dit netwerk al aan een container zijn toegekend.""" + info = inspect(name) + ips: set[str] = set() + for c in (info.get("Containers") or {}).values(): + addr = (c.get("IPv4Address") or "").split("/")[0] + if addr: + ips.add(addr) + return ips + + +def host_interfaces() -> list[str]: + """Netwerkinterfaces van de host, als suggestie voor `parent`. + + De container draait met het netwerk van de host niet gedeeld, maar /sys is + wél zichtbaar wanneer de docker-socket gemount is; lukt het lezen niet, dan + krijgt de gebruiker gewoon een leeg lijstje en vult hij het zelf in. + """ + try: + from pathlib import Path + # /sys/class/net bevat naast interfaces ook losse bestanden zoals + # 'bonding_masters'; echte interfaces zijn symlinks naar een device. + namen = sorted(p.name for p in Path("/sys/class/net").iterdir() if p.is_dir()) + return [n for n in namen + if n != "lo" and not n.startswith(("docker", "br-", "veth"))] + except Exception: + return [] + + +# ── IP-toewijzing ──────────────────────────────────────────────────────────── + +def next_free_ip(net: dict, extra_used: set[str] | None = None) -> str: + """Eerste vrije adres binnen ip_range (of subnet), gateway uitgezonderd. + + Zelfde patroon als docker.next_free_port(): een voorstel, geen reservering. + """ + subnet = net.get("ip_range") or net.get("subnet") or "" + if not subnet: + return "" + try: + network = ipaddress.ip_network(subnet, strict=False) + except ValueError: + return "" + bezet = set(used_ips(net.get("name", "")) if net.get("name") else set()) + bezet |= set(extra_used or set()) + gateway = (net.get("gateway") or "").strip() + if gateway: + bezet.add(gateway) + for host in network.hosts(): + if str(host) not in bezet: + return str(host) + return "" + + +def ip_in_network(ip: str, net: dict) -> bool: + subnet = net.get("subnet") or "" + if not subnet: + return False + try: + return ipaddress.ip_address(ip) in ipaddress.ip_network(subnet, strict=False) + except ValueError: + return False + + +# ── Aanmaken / verwijderen ─────────────────────────────────────────────────── + +def validate(net: dict) -> str: + """Retourneer een foutmelding, of "" als de definitie klopt.""" + if not _NAME_RE.match(net.get("name") or ""): + return "Ongeldige netwerknaam." + if net.get("driver") not in DRIVERS: + return f"Driver moet {' of '.join(DRIVERS)} zijn." + if not _NAME_RE.match(net.get("parent") or ""): + return "Geef een geldige host-interface op (bv. eth0)." + try: + subnet = ipaddress.ip_network(net.get("subnet") or "", strict=False) + except ValueError: + return "Ongeldig subnet (bv. 192.168.1.0/24)." + gateway = (net.get("gateway") or "").strip() + if gateway: + try: + if ipaddress.ip_address(gateway) not in subnet: + return "De gateway ligt buiten het subnet." + except ValueError: + return "Ongeldig gateway-adres." + rng = (net.get("ip_range") or "").strip() + if rng: + try: + if not ipaddress.ip_network(rng, strict=False).subnet_of(subnet): + return "De IP-range ligt buiten het subnet." + except ValueError: + return "Ongeldige IP-range (bv. 192.168.1.240/28)." + return "" + + +def create(net: dict) -> tuple[bool, str]: + """Maak het netwerk aan bij de Docker-daemon (idempotent).""" + probleem = validate(net) + if probleem: + return False, probleem + if exists(net["name"]): + return True, "Netwerk bestaat al." + cmd = [docker._bin(), "network", "create", + "-d", net["driver"], + "--subnet", net["subnet"], + "-o", f"parent={net['parent']}"] + if net.get("gateway"): + cmd += ["--gateway", net["gateway"]] + if net.get("ip_range"): + cmd += ["--ip-range", net["ip_range"]] + cmd.append(net["name"]) + r = docker._run(cmd, timeout=30) + if r.returncode != 0: + return False, (r.stderr or r.stdout).strip()[:400] + return True, "Netwerk aangemaakt." + + +def remove(name: str) -> tuple[bool, str]: + if not _NAME_RE.match(name or ""): + return False, "Ongeldige netwerknaam." + r = docker._run([docker._bin(), "network", "rm", name], timeout=30) + if r.returncode != 0: + return False, (r.stderr or r.stdout).strip()[:400] + return True, "Netwerk verwijderd." diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 342ae28..364fdfc 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -162,6 +162,7 @@ tailwind.config = {
images
+ @@ -254,13 +255,53 @@ tailwind.config = { + +
-
+ +
+ +
+ +
+ +
+ +
+
+ + Beveiliging + +
+
+
+
Gebruikers
+
+ +
+
+ + + +
+
+
+
Eigen wachtwoord wijzigen
+
+ + + + +
+
+
+
+ + +
+
+ + Netwerken + +
+
+

+ Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN + in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app + firewallen. De Docker-host zelf bereikt macvlan-containers niet zonder shim-interface. +

+
+ +
+
Nog geen netwerken.
+
+
+
@@ -577,6 +685,40 @@ tailwind.config = {
+ + + @@ -847,6 +1026,13 @@ function app() { users: [], newUser: { username: '', password: '' }, pwForm: { current: '', new: '', new2: '' }, + // Uitklapbaar containerpaneel per stack: { : {open, loading, items} } + cpanel: {}, + + // Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen + netList: [], netIfaces: [], + newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' }, + docker: { running: 0, containers: 0, images: 0 }, stacks: [], stackQuery: '', @@ -1136,6 +1322,69 @@ function app() { if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; } else this.toast(d.msg || 'Fout bij opslaan', 'er'); }, + // ── Netwerken ──────────────────────────────────────────────────────────── + async loadNetworks() { + const d = await this.rpc('/api/networks'); + this.netList = d.networks || []; + this.netIfaces = d.interfaces || []; + }, + // Vul automatisch het eerstvolgende vrije adres in als je een netwerk kiest. + pickIp() { + const n = this.netList.find(x => x.name === this.install.network); + this.install.ip = n ? (n.next_ip || '') : ''; + }, + async addNetwork() { + const d = await this.rpc('/api/networks', this.newNet); + this.toast(d.msg || (d.ok ? 'Aangemaakt' : 'Fout'), d.ok ? 'ok' : 'er'); + if (d.ok) { + this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' }; + this.modal = null; + this.loadNetworks(); + } + }, + async deleteNetwork(name) { + if (!confirm('Netwerk ' + name + ' verwijderen?')) return; + const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' }); + this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er'); + this.loadNetworks(); + }, + + // Cryptografisch willekeurige waarde voor admin-tokens en wachtwoorden. + // Lokaal gegenereerd, dus het geheim gaat niet heen en weer over het net. + randomSecret(bytes = 24) { + const buf = new Uint8Array(bytes); + crypto.getRandomValues(buf); + return btoa(String.fromCharCode(...buf)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }, + + // ── Containers binnen een stack ────────────────────────────────────────── + async toggleContainers(stack) { + const cur = this.cpanel[stack] || { open: false, loading: false, items: [] }; + if (cur.open) { this.cpanel = { ...this.cpanel, [stack]: { ...cur, open: false } }; return; } + this.cpanel = { ...this.cpanel, [stack]: { ...cur, open: true, loading: true } }; + await this.loadContainers(stack); + }, + async loadContainers(stack) { + const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/containers`); + const cur = this.cpanel[stack] || { open: true }; + this.cpanel = { ...this.cpanel, + [stack]: { ...cur, loading: false, items: d.containers || [] } }; + }, + async containerAction(stack, container, action) { + const d = await this.rpc( + `/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {}); + this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || 'Fout'), d.ok ? 'ok' : 'er'); + await this.loadContainers(stack); + this.loadStacks(); + }, + async openContainerLogs(stack, container) { + const d = await this.rpc( + `/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/logs`); + this.logs = { title: container + ' · logs', content: d.logs || '(geen output)' }; + this.modal = 'logs'; + }, + async openLogs(name) { const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`); this.logs = { title: name+' · logs', content: d.logs || '(geen output)' }; @@ -1175,9 +1424,11 @@ function app() { format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose', loading: true, envVars: [], images: [], envOverrides: {}, imageTags: {}, - fields: [], values: {}, preview_files: {} + fields: [], values: {}, preview_files: {}, + network: '', ip: '' }; this.modal = 'install'; + if (!this.netList.length) await this.loadNetworks(); const d = await this.rpc('/api/store/preview', { stack: this.install.stack, repo_id: rid }); this.install.loading = false; if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; } @@ -1231,6 +1482,7 @@ function app() { repo_id: this.install.repo_id, instance: this.install.instance || this.install.stack, }; + if (this.install.network) { body.network = this.install.network; body.ip = this.install.ip; } if (this.install.format === 'boilerplate') body.values = this.install.values; else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; } const d = await this.rpc('/api/store/install', body); @@ -1261,7 +1513,12 @@ function app() { this.loadAudit(); }, - async loadSettings() { const d = await this.rpc('/api/settings'); this.settings = d || {}; await this.loadAppStore(); }, + async loadSettings() { + const d = await this.rpc('/api/settings'); this.settings = d || {}; + await this.loadAppStore(); + await this.loadUsers(); + await this.loadNetworks(); + }, autoSave() { if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(async () => { diff --git a/tests/test_compose_transform.py b/tests/test_compose_transform.py new file mode 100644 index 0000000..08afdfb --- /dev/null +++ b/tests/test_compose_transform.py @@ -0,0 +1,86 @@ +"""Poorten → eigen IP-adres (macvlan/ipvlan).""" +import sys +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up")) + +from core import compose_transform as ct + +BASIS = """\ +services: + vaultwarden: + image: vaultwarden/server:latest + ports: + - "8222:80" + volumes: + - /data:/data +""" + + +def test_poorten_verdwijnen_en_ip_wordt_toegekend(): + doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240")) + svc = doc["services"]["vaultwarden"] + assert "ports" not in svc + assert svc["networks"] == {"lan": {"ipv4_address": "192.168.1.240"}} + assert doc["networks"] == {"lan": {"external": True}} + + +def test_overige_instellingen_blijven_intact(): + doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240")) + svc = doc["services"]["vaultwarden"] + assert svc["image"] == "vaultwarden/server:latest" + assert svc["volumes"] == ["/data:/data"] + + +def test_alleen_de_publicerende_service_krijgt_het_ip(): + tekst = """\ +services: + db: + image: postgres + web: + image: nginx + ports: + - "8080:80" +""" + doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.5")) + assert doc["services"]["web"]["networks"] == {"lan": {"ipv4_address": "10.0.0.5"}} + # De database blijft in hetzelfde netwerk zodat web hem kan vinden. + assert doc["services"]["db"]["networks"] == ["lan"] + assert "ports" not in doc["services"]["db"] + + +def test_expliciete_service_kan_gekozen_worden(): + tekst = "services:\n a:\n image: x\n b:\n image: y\n ports: ['1:1']\n" + doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.9", only_service="a")) + assert doc["services"]["a"]["networks"] == {"lan": {"ipv4_address": "10.0.0.9"}} + assert doc["services"]["b"]["networks"] == ["lan"] + + +def test_zonder_poorten_krijgt_de_eerste_service_het_ip(): + doc = yaml.safe_load(ct.to_dedicated_ip( + "services:\n solo:\n image: x\n", "lan", "10.0.0.2")) + assert doc["services"]["solo"]["networks"] == {"lan": {"ipv4_address": "10.0.0.2"}} + + +@pytest.mark.parametrize("tekst,fout", [ + ("dit: [is geen: geldige yaml", "geldige YAML"), + ("version: '3'\n", "geen services"), + ("- een\n- lijst\n", "mapping"), +]) +def test_foutmeldingen(tekst, fout): + with pytest.raises(ct.TransformError) as e: + ct.to_dedicated_ip(tekst, "lan", "10.0.0.1") + assert fout in str(e.value) + + +def test_onbekende_service_geeft_fout(): + with pytest.raises(ct.TransformError): + ct.to_dedicated_ip(BASIS, "lan", "10.0.0.1", only_service="bestaat-niet") + + +def test_published_ports(): + assert ct.published_ports(BASIS) == ["8222:80"] + assert ct.published_ports("services:\n a:\n image: x\n") == [] diff --git a/tests/test_networks.py b/tests/test_networks.py new file mode 100644 index 0000000..ffd85bc --- /dev/null +++ b/tests/test_networks.py @@ -0,0 +1,77 @@ +"""Validatie en IP-toewijzing voor macvlan/ipvlan-netwerken.""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up")) + +from core import networks + +GELDIG = {"name": "lan", "driver": "macvlan", "parent": "eth0", + "subnet": "192.168.1.0/24", "gateway": "192.168.1.1", + "ip_range": "192.168.1.240/28"} + + +def test_geldige_definitie(): + assert networks.validate(GELDIG) == "" + + +@pytest.mark.parametrize("wijziging,fragment", [ + ({"name": "../boos"}, "netwerknaam"), + ({"name": ""}, "netwerknaam"), + ({"driver": "bridge"}, "macvlan"), + ({"parent": "eth0; rm -rf /"}, "host-interface"), + ({"subnet": "geen-subnet"}, "subnet"), + ({"gateway": "10.9.9.1"}, "gateway ligt buiten"), + ({"gateway": "nogal-fout"}, "gateway-adres"), + ({"ip_range": "10.9.9.0/24"}, "range ligt buiten"), + ({"ip_range": "onzin"}, "IP-range"), +]) +def test_ongeldige_definities(wijziging, fragment): + net = {**GELDIG, **wijziging} + assert fragment in networks.validate(net) + + +def test_next_free_ip_respecteert_range_en_gateway(monkeypatch): + monkeypatch.setattr(networks, "used_ips", lambda naam: set()) + # .240/28 loopt van .240 t/m .255; .240 is het netwerkadres, dus .241 eerst. + assert networks.next_free_ip(GELDIG) == "192.168.1.241" + + +def test_next_free_ip_slaat_bezette_adressen_over(monkeypatch): + monkeypatch.setattr(networks, "used_ips", + lambda naam: {"192.168.1.241", "192.168.1.242"}) + assert networks.next_free_ip(GELDIG) == "192.168.1.243" + + +def test_next_free_ip_houdt_rekening_met_reserveringen(monkeypatch): + monkeypatch.setattr(networks, "used_ips", lambda naam: set()) + assert networks.next_free_ip(GELDIG, {"192.168.1.241"}) == "192.168.1.242" + + +def test_next_free_ip_zonder_range_gebruikt_subnet(monkeypatch): + monkeypatch.setattr(networks, "used_ips", lambda naam: set()) + net = {**GELDIG, "ip_range": ""} + assert networks.next_free_ip(net) == "192.168.1.2" # .1 is de gateway + + +def test_next_free_ip_leeg_als_alles_bezet(monkeypatch): + bezet = {f"192.168.1.{i}" for i in range(240, 256)} + monkeypatch.setattr(networks, "used_ips", lambda naam: bezet) + assert networks.next_free_ip(GELDIG) == "" + + +@pytest.mark.parametrize("ip,verwacht", [ + ("192.168.1.240", True), + ("192.168.2.1", False), + ("geen-ip", False), +]) +def test_ip_in_network(ip, verwacht): + assert networks.ip_in_network(ip, GELDIG) is verwacht + + +def test_remove_weigert_ongeldige_naam(): + ok, msg = networks.remove("../etc") + assert ok is False + assert "netwerknaam" in msg diff --git a/tests/test_stack_api.py b/tests/test_stack_api.py new file mode 100644 index 0000000..0e9b8aa --- /dev/null +++ b/tests/test_stack_api.py @@ -0,0 +1,82 @@ +"""Routes rond stacks: containerbeheer en de netwerkkeuze bij installeren.""" +from conftest import login + + +def test_containers_van_onbekende_stack(client): + login(client) + r = client.get("/api/stacks/bestaatniet/containers") + assert r.status_code == 404 + + +def test_containeractie_alleen_binnen_eigen_stack(client): + """De containernaam wordt getoetst aan compose_ps van díé stack, zodat deze + route nooit een willekeurige container op de host kan raken.""" + csrf = login(client) + r = client.post("/api/stacks/mijnstack/containers/traefik/stop", + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 404 + assert "hoort niet bij deze stack" in r.get_json()["msg"] + + +def test_onbekende_containeractie(client): + csrf = login(client) + r = client.post("/api/stacks/mijnstack/containers/web/verwijder", + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + + +def test_containerlogs_alleen_binnen_eigen_stack(client): + login(client) + r = client.get("/api/stacks/mijnstack/containers/vreemde/logs") + assert r.status_code == 404 + + +def test_install_weigert_onbekend_netwerk(client): + csrf = login(client) + r = client.post("/api/store/install", + json={"stack": "vaultwarden", "repo_id": "server-up", + "instance": "vw", "network": "bestaatniet"}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + assert "niet geconfigureerd" in r.get_json()["msg"] + + +def test_install_weigert_ip_buiten_subnet(client, env): + csrf = login(client) + env["core"].patch({"NETWORKS": [{ + "name": "lan", "driver": "macvlan", "parent": "eth0", + "subnet": "192.168.1.0/24", "gateway": "192.168.1.1"}]}) + r = client.post("/api/store/install", + json={"stack": "vaultwarden", "repo_id": "server-up", + "instance": "vw", "network": "lan", "ip": "10.9.9.9"}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + assert "buiten het subnet" in r.get_json()["msg"] + + +def test_netwerk_toevoegen_valideert(client): + csrf = login(client) + r = client.post("/api/networks", + json={"name": "lan", "driver": "macvlan", + "parent": "eth0; id", "subnet": "192.168.1.0/24"}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + + +def test_netwerk_verwijderen_onbekend(client): + csrf = login(client) + r = client.delete("/api/networks/bestaatniet", headers={"X-CSRF-Token": csrf}) + assert r.status_code == 404 + + +def test_gereserveerde_ips_worden_gelezen_uit_stacks(client, env): + """Een geïnstalleerde stack houdt zijn adres, ook als hij gestopt is.""" + import json + login(client) + stack = env["lib"] / "vaultwarden" + stack.mkdir() + (stack / ".serverup.json").write_text(json.dumps({"ip": "192.168.1.241"})) + import app as app_module + with app_module.app.test_request_context(): + assert "192.168.1.241" in app_module._reserved_ips() + assert "192.168.1.241" not in app_module._reserved_ips(exclude_stack="vaultwarden")