v0.5.00-beta - containerbeheer, eigen IP-adressen, veiliger installeren
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 3s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-26 14:51:32 +02:00
parent 916e54a229
commit 8b29553a71
11 changed files with 1445 additions and 14 deletions

View file

@ -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 `<img>`-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 # v0.4.60 — Tweecijferig patch-nummer
## Versiebeleid vanaf v0.4.60 ## Versiebeleid vanaf v0.4.60

111
docs/beveiliging.md Normal file
View file

@ -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.

134
docs/netwerken.md Normal file
View file

@ -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 |

View file

@ -15,6 +15,7 @@ from flask import Flask, Response, jsonify, render_template, request, session
import core as cfg import core as cfg
from core import audit, jobs, i18n from core import audit, jobs, i18n
from core import auth, docker, git, boilerplates, updater from core import auth, docker, git, boilerplates, updater
from core import compose_transform, networks
from core.modules import Module, CORE, discover from core.modules import Module, CORE, discover
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
@ -391,16 +392,20 @@ def api_stacks():
except Exception: except Exception:
pass pass
def _stack_logo(d): def _stack_meta(d) -> dict:
# 1) Voorkeur: opgeslagen metadata bij installatie
mf = d / ".serverup.json" mf = d / ".serverup.json"
if mf.exists(): if mf.exists():
try: try:
m = json.loads(mf.read_text("utf-8")) return json.loads(mf.read_text("utf-8"))
if m.get("logo_url") or m.get("icon"):
return m.get("logo_url", ""), m.get("icon", "")
except Exception: except Exception:
pass 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) # 2) Fallback: match op naam met de store (ook voor oudere installaties)
if d.name in icon_map: if d.name in icon_map:
h = icon_map[d.name]; return h["logo_url"], h["icon"] h = icon_map[d.name]; return h["logo_url"], h["icon"]
@ -417,11 +422,13 @@ def api_stacks():
continue continue
ct = docker.compose_ps(d, name=d.name) ct = docker.compose_ps(d, name=d.name)
logo_url, icon = _stack_logo(d) logo_url, icon = _stack_logo(d)
meta = _stack_meta(d)
out.append({ out.append({
"name": d.name, "path": str(d), "has_compose": True, "name": d.name, "path": str(d), "has_compose": True,
"running": any(c["running"] for c in ct), "running": any(c["running"] for c in ct),
"containers": ct, "containers": ct,
"logo_url": logo_url, "icon": icon, "logo_url": logo_url, "icon": icon,
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
}) })
return jsonify(out) return jsonify(out)
@ -536,10 +543,66 @@ def api_stack_compose_put(name):
lib, d = _stack_dir(name) lib, d = _stack_dir(name)
if not d: if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400 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) 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/<name>/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/<name>/containers/<container>/<action>", 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/<name>/containers/<container>/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/<name>/logs") @app.route("/api/stacks/<name>/logs")
def api_stack_logs(name): def api_stack_logs(name):
lib, d = _stack_dir(name) lib, d = _stack_dir(name)
@ -664,6 +727,47 @@ def _find_stack_src(stack: str, rid: str) -> Path | None:
return 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"]) @app.route("/api/store/preview", methods=["POST"])
def api_store_preview(): def api_store_preview():
"""Preview een stack: lees compose + .env en extract configureerbare velden. """Preview een stack: lees compose + .env en extract configureerbare velden.
@ -681,7 +785,7 @@ def api_store_preview():
# ── Boilerplate (ChristianLempa-formaat) ────────────────────────────────── # ── Boilerplate (ChristianLempa-formaat) ──────────────────────────────────
if boilerplates.is_boilerplate(src): if boilerplates.is_boilerplate(src):
meta = boilerplates.metadata(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 # Render een preview met defaults, zodat de gebruiker ziet wat er komt
try: try:
ctx = boilerplates.build_context(src, {}) ctx = boilerplates.build_context(src, {})
@ -724,16 +828,25 @@ def api_store_preview():
k, v = k.strip(), v.strip() k, v = k.strip(), v.strip()
is_port = "PORT" in k.upper() 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") 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, "key": k, "value": v,
"is_port": is_port, "is_tag": is_tag, "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 "", "desc": "Poort" if is_port else "Image tag" if is_tag else "",
}) }
if is_port: if is_port:
try: try:
result["ports"].append({"key": k, "value": int(v)}) poort = int(v)
except ValueError: except ValueError:
result["ports"].append({"key": k, "value": v}) 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 # Parse compose voor images
cf = docker.find_compose(src) cf = docker.find_compose(src)
@ -787,8 +900,24 @@ def api_store_install():
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"} env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"} image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
bp_values = d.get("values", {}) # boilerplate variable values 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: if not stack:
return jsonify(ok=False, msg="stack vereist") 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 # De instantienaam bepaalt de doelmap én de compose-projectnaam. Zonder
# controle schrijft `"instance": "../../etc"` buiten de library. # controle schrijft `"instance": "../../etc"` buiten de library.
if not cfg.safe_name(inst): if not cfg.safe_name(inst):
@ -897,6 +1026,30 @@ def api_store_install():
cf.write_text(new_content) cf.write_text(new_content)
jobs.log(qq, "ok", "Image tags aangepast") 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 # Data dir
try: try:
Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True) 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), "name": meta.get("name", stack),
"logo_url": logo, "logo_url": logo,
"icon": meta.get("icon", ""), "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") }, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception: except Exception:
pass pass
@ -939,6 +1096,97 @@ def api_store_install():
return jsonify(ok=True, job_id=jid) 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/<name>", 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 ────────────────────────────────────────────────────────────── # ── Module Store ──────────────────────────────────────────────────────────────
@app.route("/api/modules") @app.route("/api/modules")

View file

@ -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

View file

@ -137,6 +137,41 @@ def restart_container(name: str) -> tuple[bool, str]:
return r.returncode == 0, (r.stderr or r.stdout).strip() 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 ─────────────────────────────────────────────────────────── # ── Compose helpers ───────────────────────────────────────────────────────────
def find_compose(d: Path) -> Path | None: def find_compose(d: Path) -> Path | None:
@ -244,6 +279,52 @@ def write_compose(d: Path, content: str):
f.write_text(content) 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]: def used_ports() -> set[int]:
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10) r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
ports = set() ports = set()

181
server-up/core/networks.py Normal file
View file

@ -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."

View file

@ -162,6 +162,7 @@ tailwind.config = {
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div> <div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div>
</div> </div>
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button> <button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
<button @click="logout()" :title="'Uitloggen (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
</div> </div>
</div> </div>
</aside> </aside>
@ -254,13 +255,53 @@ tailwind.config = {
<template x-if="!s.logo_url"><span class="w-8 h-8 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-base shrink-0" x-text="s.icon || '📦'"></span></template> <template x-if="!s.logo_url"><span class="w-8 h-8 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-base shrink-0" x-text="s.icon || '📦'"></span></template>
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="!s.has_compose ? 'bg-wr' : s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span> <span class="w-2.5 h-2.5 rounded-full shrink-0" :class="!s.has_compose ? 'bg-wr' : s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span> <span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
:title="'Eigen IP op netwerk ' + s.network" x-text="s.ip"></span>
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span> <span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
</div> </div>
<div class="p-4 flex flex-col gap-3 flex-1"> <div class="p-4 flex flex-col gap-3 flex-1">
<div class="flex flex-wrap gap-1.5" x-show="s.containers && s.containers.length"> <!-- Containerchips; klik klapt het beheerpaneel per container uit -->
<div class="flex flex-wrap gap-1.5 items-center cursor-pointer"
x-show="s.containers && s.containers.length"
@click="toggleContainers(s.name)">
<template x-for="c in s.containers || []" :key="c.name"> <template x-for="c in s.containers || []" :key="c.name">
<span class="text-[11px] font-mono px-2 py-0.5 rounded-md border" :class="c.running ? 'border-ok/30 text-ok bg-ok/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" x-text="c.service || c.name"></span> <span class="text-[11px] font-mono px-2 py-0.5 rounded-md border" :class="c.running ? 'border-ok/30 text-ok bg-ok/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" x-text="c.service || c.name"></span>
</template> </template>
<span class="mdi text-t2 dark:text-t2-dark text-base ml-auto"
:class="cpanel[s.name] && cpanel[s.name].open ? 'mdi-chevron-up' : 'mdi-chevron-down'"></span>
</div>
<div x-show="cpanel[s.name] && cpanel[s.name].open" x-cloak
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
<template x-if="cpanel[s.name] && cpanel[s.name].loading">
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
<span class="mdi mdi-loading mdi-spin"></span> Laden…
</div>
</template>
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
<div class="px-3 py-2.5 bg-s2/40 dark:bg-s2-dark/40">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :class="c.running ? 'bg-ok' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="text-xs font-semibold text-wh dark:text-wh-dark truncate flex-1" x-text="c.service || c.name"></span>
<button class="btn-sm !h-7 !px-2" :title="c.running ? t('stop') : t('start')"
@click.stop="containerAction(s.name, c.name, c.running ? 'stop' : 'start')">
<span class="mdi" :class="c.running ? 'mdi-stop' : 'mdi-play'"></span>
</button>
<button class="btn-sm !h-7 !px-2" :title="t('restart')"
@click.stop="containerAction(s.name, c.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm !h-7 !px-2" :title="t('logs')"
@click.stop="openContainerLogs(s.name, c.name)"><span class="mdi mdi-text-box-outline"></span></button>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-[10px] font-mono text-t2 dark:text-t2-dark"
x-show="c.stats && c.stats.cpu">
<span>CPU <span class="text-tx dark:text-tx-dark" x-text="c.stats.cpu"></span></span>
<span>MEM <span class="text-tx dark:text-tx-dark" x-text="c.stats.mem"></span></span>
<span x-show="c.stats.net">NET <span class="text-tx dark:text-tx-dark" x-text="c.stats.net"></span></span>
</div>
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.ports" x-text="c.ports"></div>
</div>
</template>
</div> </div>
<div class="flex flex-wrap gap-1.5 mt-auto" x-show="s.has_compose"> <div class="flex flex-wrap gap-1.5 mt-auto" x-show="s.has_compose">
<button class="btn-sm flex-1 min-w-[84px]" :class="s.running ? 'btn-danger' : 'btn-primary'" @click="stackAction(s.name, s.running ? 'stop' : 'start')"> <button class="btn-sm flex-1 min-w-[84px]" :class="s.running ? 'btn-danger' : 'btn-primary'" @click="stackAction(s.name, s.running ? 'stop' : 'start')">
@ -422,6 +463,73 @@ tailwind.config = {
</div> </div>
</div> </div>
<!-- Beveiliging: accounts + wachtwoord -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Beveiliging</span>
<span class="badge badge-dim ml-auto" x-text="'ingelogd als ' + auth.user"></span>
</div>
<div class="p-5 grid md:grid-cols-2 gap-6">
<div>
<div class="form-label">Gebruikers</div>
<div class="space-y-1.5 mb-3">
<template x-for="u in users" :key="u">
<div class="flex items-center gap-2 text-sm">
<span class="mdi mdi-account-outline text-t2 dark:text-t2-dark"></span>
<span class="text-tx dark:text-tx-dark flex-1" x-text="u"></span>
<button class="btn-sm !h-7 !px-2 btn-danger" x-show="users.length > 1"
@click="delUser(u)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
</div>
<div class="grid gap-2">
<input class="form-input" x-model="newUser.username" placeholder="Gebruikersnaam">
<input type="password" class="form-input" x-model="newUser.password" placeholder="Wachtwoord (min. 10 tekens)">
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> Gebruiker toevoegen</button>
</div>
</div>
<div>
<div class="form-label">Eigen wachtwoord wijzigen</div>
<div class="grid gap-2">
<input type="password" class="form-input" x-model="pwForm.current" placeholder="Huidig wachtwoord">
<input type="password" class="form-input" x-model="pwForm.new" placeholder="Nieuw wachtwoord">
<input type="password" class="form-input" x-model="pwForm.new2" placeholder="Nieuw wachtwoord herhalen">
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> Wijzigen</button>
</div>
</div>
</div>
</div>
<!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Netwerken</span>
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> Netwerk</button>
</div>
<div class="p-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-3">
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.
</p>
<div class="space-y-2">
<template x-for="n in netList" :key="n.name">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<span class="w-2 h-2 rounded-full shrink-0" :class="n.created ? 'bg-ok' : 'bg-wr'"></span>
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
<span class="badge badge-dim" x-text="n.driver"></span>
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip">volgend vrij: <span class="font-mono" x-text="n.next_ip"></span></span>
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
</div>
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark">Nog geen netwerken.</div>
</div>
</div>
<!-- Updates --> <!-- Updates -->
<div class="card"> <div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark"> <div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
@ -577,6 +685,40 @@ tailwind.config = {
</div> </div>
</div> </div>
<!-- Netwerk toevoegen -->
<div x-show="modal==='add-network'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> Netwerk toevoegen</div>
<div class="modal-body space-y-3">
<div><label class="form-label">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label">Driver</label>
<select class="form-input" x-model="newNet.driver">
<option value="macvlan">macvlan</option>
<option value="ipvlan">ipvlan</option>
</select></div>
<div><label class="form-label">Host-interface</label>
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
<div><label class="form-label">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
</div>
<div><label class="form-label">IP-range (optioneel)</label>
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server
hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.
</p>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> Aanmaken</button>
</div>
</div>
<!-- Install --> <!-- Install -->
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]"> <div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head"> <div class="modal-head">
@ -591,6 +733,32 @@ tailwind.config = {
<input class="form-input" x-model="install.instance"> <input class="form-input" x-model="install.instance">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
</div> </div>
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
<div>
<label class="form-label">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()">
<option value="">Poorten op de host (standaard)</option>
<template x-for="n in netList" :key="n.name">
<option :value="n.name" x-text="n.name + ' — eigen IP (' + n.subnet + ')'"></option>
</template>
</select>
<template x-if="install.network">
<div class="mt-2">
<label class="form-label">IP-adres</label>
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app
is bereikbaar op zijn eigen poort. Let op: de Docker-host zelf kan een
macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).
</p>
</div>
</template>
<p x-show="!install.network && netList.length === 0"
class="text-[11px] text-dm dark:text-dm-dark mt-1">
Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
</p>
</div>
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div> <div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
<!-- Boilerplate fields --> <!-- Boilerplate fields -->
@ -618,7 +786,18 @@ tailwind.config = {
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label> <label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
</template> </template>
<template x-if="f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template> <template x-if="f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
<template x-if="f.type === 'str'"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template> <template x-if="f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
<!-- Geheim veld: genereerknop, zodat niemand 'admin123' invult -->
<template x-if="f.type === 'str' && f.secret">
<div class="flex gap-2">
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
<button type="button" class="btn-sm shrink-0" title="Genereer een willekeurige waarde"
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
</div>
</template>
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1">
Poort <span x-text="f.port_taken"></span> is al in gebruik — <span x-text="f.suggested"></span> voorgesteld.
</p>
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p> <p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
</div> </div>
</template> </template>
@ -847,6 +1026,13 @@ function app() {
users: [], newUser: { username: '', password: '' }, users: [], newUser: { username: '', password: '' },
pwForm: { current: '', new: '', new2: '' }, pwForm: { current: '', new: '', new2: '' },
// Uitklapbaar containerpaneel per stack: { <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 }, docker: { running: 0, containers: 0, images: 0 },
stacks: [], stacks: [],
stackQuery: '', stackQuery: '',
@ -1136,6 +1322,69 @@ function app() {
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; } if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
else this.toast(d.msg || 'Fout bij opslaan', 'er'); 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) { async openLogs(name) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`); const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
this.logs = { title: name+' · logs', content: d.logs || '(geen output)' }; this.logs = { title: name+' · logs', content: d.logs || '(geen output)' };
@ -1175,9 +1424,11 @@ function app() {
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose', format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
loading: true, loading: true,
envVars: [], images: [], envOverrides: {}, imageTags: {}, envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {} fields: [], values: {}, preview_files: {},
network: '', ip: ''
}; };
this.modal = 'install'; 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 }); const d = await this.rpc('/api/store/preview', { stack: this.install.stack, repo_id: rid });
this.install.loading = false; this.install.loading = false;
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; } 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, repo_id: this.install.repo_id,
instance: this.install.instance || this.install.stack, 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; 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; }
const d = await this.rpc('/api/store/install', body); const d = await this.rpc('/api/store/install', body);
@ -1261,7 +1513,12 @@ function app() {
this.loadAudit(); 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() { autoSave() {
if (this.saveTimer) clearTimeout(this.saveTimer); if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(async () => { this.saveTimer = setTimeout(async () => {

View file

@ -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") == []

77
tests/test_networks.py Normal file
View file

@ -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

82
tests/test_stack_api.py Normal file
View file

@ -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")