v0.7.70-beta - invulmenu bij installeren, geheimen naar .env
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m34s

Invulmenu:
- Vaste stappen (Basis, Verbinden, Instellingen, Toegang, Controleren) in
  plaats van één lange lijst. Eén stap per groep werkt niet: de mediane app
  heeft één groep, arr-stack eenentwintig. Lege stappen worden overgeslagen.
- Volgende controleert de verplichte velden van die stap; die melding kwam
  eerder pas bij het installeren.
- Geavanceerde opties achter één schakelaar die op elke stap zichtbaar is,
  plus 'Alles op één pagina' voor het oude gedrag. Beide onthouden.

Geheimen naar .env:
- compose_transform.geheimen_naar_env() vervangt geheime waarden door
  ${NAAM} en schrijft ze naar .env met rechten 0600. Werkt ook midden in
  een database-URL. Poorten en paden blijven leesbaar in compose.
- .serverup.json bevat geen geheimen meer en krijgt ook 0600; daar stonden
  ze wereldleesbaar in. Het herconfiguratieformulier leest ze terug uit
  .env via een bewaarde veld-naar-sleutel-koppeling.
- docs/beveiliging.md legt uit wat dit niet oplost: docker inspect toont de
  waarde nog steeds.

Bewerken:
- Laatste stap toont het gerenderde resultaat met jouw waarden en is
  bewerkbaar; compose_override/env_override gaan mee bij het installeren.
  Ongeldige YAML wordt geweigerd voordat er een map bestaat.
- De bestaande editor heeft tabbladen (compose/.env) en biedt herstarten na
  opslaan. De .env-PUT weigert een regel zonder '=' en logt in audit.

Inloggegevens:
- /api/stacks/<n>/credentials plus paneel op de stackkaart: adres,
  gebruikersnaam, wachtwoord achter een oog-knop, kopieerknop. Alleen voor
  beheerders, elk bekijken komt in het auditlog.
- Nieuw 'credential'-kenmerk in template.json met naamherkenning als
  terugval, zodat de bestaande 96 sjablonen meteen werken.

Herstel van een regressie uit v0.7.60: acht inlogwachtwoorden hadden hun
genereerknop verloren toen die aan 'generate' werd gehangen. Terug, behalve
waar een verzonnen waarde fout is (WireGuard-sleutel, externe tokens).

De menulogica wordt getest door de echte component uit index.html in Node uit
te voeren; slaat zichzelf over waar node ontbreekt. 2295 tests groen.

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-28 21:31:51 +02:00
parent 952e7ddb7a
commit bc55252267
24 changed files with 1806 additions and 203 deletions

View file

@ -1,3 +1,66 @@
# v0.7.70-beta — Installeren via een invulmenu, geheimen naar .env
**Het installatieformulier is een invulmenu geworden.** Eén lange lijst werkte
slecht aan beide uiteinden: de mediane app heeft vijf velden in één groep, en de
ARR-stack tweeënvijftig in eenentwintig groepen. Nu zijn er vaste stappen —
Basis, Verbinden, Instellingen, Toegang, Controleren — waarbij een lege stap
wordt overgeslagen. Een eenvoudige app krijgt er dus drie, de ARR-stack vijf.
**Volgende** controleert de verplichte velden van díé stap en noemt wat er nog
leeg is. Tot nu toe kwam die melding pas als je op Installeren drukte. De
stapindicator is klikbaar om terug te springen.
**Geavanceerde opties zitten achter één schakelaar** die op elke stap zichtbaar
is, in plaats van een uitklapblok per groep dat je steeds opnieuw moest zoeken.
Een badge toont hoeveel velden verborgen zijn. Er is ook een schakelaar **Alles
op één pagina** voor wie het oude gedrag wil; beide keuzes worden onthouden.
**Geheimen gaan naar `.env`.** Wachtwoorden en sleutels staan niet meer in het
compose-bestand maar als `${NAAM}` met de waarde in `.env`, geschreven met
rechten `0600`. Poorten, paden en tijdzone blijven leesbaar in compose, zodat je
nog steeds in één bestand kunt zien wat er draait. Ook `.serverup.json` bevat
geen geheimen meer en krijgt `0600` — daar stonden ze tot nu toe wereldleesbaar
in.
Eerlijk over de grens hiervan: compose vult `${...}` in bij het inlezen, dus
`docker inspect` toont de waarde nog steeds. Wat je wint is dat het
compose-bestand deelbaar wordt en dat alles op één afgeschermde plek staat.
`docs/beveiliging.md` legt uit wanneer je verder wilt gaan met `secrets:` van
Compose, en waarom een externe kluis hier een slecht idee is.
**Compose en `.env` zijn nu ook vóór het installeren te bewerken.** De laatste
stap toont het gerenderde resultaat met de waarden die je hebt ingevuld; met
Bewerken pas je het aan en gaat jouw versie mee in plaats van het sjabloon.
Ongeldige YAML wordt geweigerd voordat er een map is aangemaakt. De editor voor
bestaande stacks heeft tabbladen gekregen (je moest hem sluiten en opnieuw
openen om van compose naar `.env` te gaan) en biedt aan te herstarten na
opslaan. De `.env`-editor weigert nu een regel zonder `=` — compose negeert die
stilzwijgend, waarna er ineens een wachtwoord mist — en schrijft een auditregel.
**Nieuw: Inloggegevens per app.** Een knop op de stackkaart toont het adres, de
gebruikersnaam en het wachtwoord, met een oog-knop en een kopieerknop. Bij een
gegenereerd wachtwoord kon je dat nergens meer terugvinden. Apps die je bij het
eerste bezoek zelf een account laten aanmaken (Vaultwarden, Immich) melden dat.
Alleen beheerders kunnen erbij en elk bekijken komt in het auditlog.
**Herstel van een regressie uit v0.7.60.** Ik had de genereerknop toen aan
`generate` gehangen in plaats van aan `secret`, waardoor vijftien velden hun knop
kwijtraakten. Acht daarvan zijn inlogwachtwoorden (InfluxDB, Kimai, Linkding,
Miniflux, PhotoPrism, Pi-hole, Technitium, ChangeDetection) en die hebben hem
terug. De zeven waar een verzonnen waarde juist fout is — een WireGuard-
privésleutel, een Beszel-agentsleutel, tokens uit een andere app — houden geen
knop.
Bij het uitzoeken bleek ook dat ik in v0.7.60 twee dingen verkeerd heb gemeld.
De genereerknop wérkte destijds wel (de vlag werd gezet in `app.py`, niet in
`boilerplates.py` waar ik keek), en er wás al een compose- en `.env`-editor.
De logica van het invulmenu wordt nu getest door de echte component uit
`index.html` in Node uit te voeren; die test slaat zichzelf over waar Node
ontbreekt, zoals in het bouwimage. 2295 tests groen.
---
# v0.7.60-beta — App-instellingen die niet meer stilzwijgend misgaan # v0.7.60-beta — App-instellingen die niet meer stilzwijgend misgaan
Aanleiding: Homepage weigerde na installatie met **"Host validation failed"**. Aanleiding: Homepage weigerde na installatie met **"Host validation failed"**.

View file

@ -1 +1 @@
0.7.60-beta 0.7.70-beta

View file

@ -59,7 +59,9 @@
"description": "Optioneel wachtwoord om de webinterface te beveiligen. Laat leeg voor geen beveiliging.", "description": "Optioneel wachtwoord om de webinterface te beveiligen. Laat leeg voor geen beveiliging.",
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
} }

View file

@ -84,7 +84,9 @@
"description": "Minimaal 8 tekens", "description": "Minimaal 8 tekens",
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
} }

View file

@ -82,7 +82,9 @@
"description": "Minimaal 8 tekens", "description": "Minimaal 8 tekens",
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
} }

View file

@ -64,7 +64,9 @@
"required": true, "required": true,
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
} }

View file

@ -58,7 +58,9 @@
"required": true, "required": true,
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
}, },

View file

@ -67,7 +67,9 @@
"description": "Minimaal 8 tekens", "description": "Minimaal 8 tekens",
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
}, },
{ {
"name": "db_password", "name": "db_password",

View file

@ -75,7 +75,9 @@
"description": "Wachtwoord voor het Pi-hole webpaneel", "description": "Wachtwoord voor het Pi-hole webpaneel",
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
} }

View file

@ -61,7 +61,9 @@
"description": "Wachtwoord voor de webinterface", "description": "Wachtwoord voor de webinterface",
"config": { "config": {
"placeholder": "sterk-wachtwoord" "placeholder": "sterk-wachtwoord"
} },
"generate": true,
"secret": true
} }
] ]
} }

View file

@ -185,6 +185,42 @@ keuze; die moet gewoon willekeurig zijn. Zet daarvoor `generate`:
> installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert > installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert
> sjablonen met een vaste waarde op een geheim veld. > sjablonen met een vaste waarde op een geheim veld.
### In welke stap komt een veld?
Het installatieformulier is een invulmenu met vaste stappen. Server Up leidt af
waar een veld hoort, en meestal klopt dat:
| Stap | Wat er terechtkomt |
|---|---|
| **Basis** | `service_name`, poortvelden (type `int` met `port` in de naam), `data_dir`, `timezone`, `puid`/`pgid` |
| **Verbinden** | velden met `connect` |
| **Toegang** | alles wat als geheim geldt, plus gebruikersnamen en e-mailadressen |
| **Instellingen** | de rest |
Klopt dat voor jouw veld niet, zet het dan zelf:
```json
{"name": "retention", "type": "str", "title": "Bewaartermijn", "step": "basis"}
```
Geldige waarden: `basis`, `verbinden`, `instellingen`, `geheimen`. Een onbekende
waarde valt terug op `instellingen`.
### Inloggegevens
Velden met `credential` komen terug in het paneel **Inloggegevens** op de
stackkaart, zodat iemand na de installatie kan opzoeken waarmee hij moet
inloggen — handig bij een gegenereerd wachtwoord.
```json
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
"default": "admin", "credential": "username"}
```
Meestal hoef je dit niet te zetten: een veld met `user`, `login` of `email` in de
naam geldt als `username`, en een geheim veld met `password` erin als `password`.
Tokens en sleutels tellen bewust níét mee — daar log je niet mee in.
### Verplichte velden ### Verplichte velden
`"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op `"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op

View file

@ -106,13 +106,49 @@ je vertrouwt. Twee knoppen om aan te draaien:
{"id": "mijn-apps", "url": "https://…", "branch": "main", "commit": "a1b2c3d"} {"id": "mijn-apps", "url": "https://…", "branch": "main", "commit": "a1b2c3d"}
``` ```
## Waar de wachtwoorden van je apps staan
Vul je bij het installeren een wachtwoord of sleutel in, dan komt die waarde
**niet** in het compose-bestand terecht maar in `.env` naast de stack:
```yaml
# docker-compose.yml — te delen, te committen
environment:
- TZ=Europe/Amsterdam
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
```
```bash
# .env — rechten 0600, alleen leesbaar voor de eigenaar
POSTGRES_PASSWORD=aB3dE6gH9jK2mN5p
```
Ook `.serverup.json`, waarin de gemaakte keuzes staan zodat je ze later kunt
wijzigen, bevat geen geheimen meer en krijgt `0600`. Tot v0.7.60 stonden
wachtwoorden op allebei die plekken wereldleesbaar.
**Wat dit wél oplost:** het compose-bestand is deelbaar en te committen, alle
geheimen staan op één plek, en die plek is afgeschermd.
**Wat dit níét oplost:** compose vult `${...}` in bij het inlezen, dus de
container krijgt gewoon de letterlijke waarde en `docker inspect` toont hem nog
steeds. Wie de docker-socket kan bereiken, kan de wachtwoorden van je apps lezen
— maar die kan sowieso al elke container overnemen.
Wil je een geheim écht buiten `docker inspect` houden, dan is `secrets:` van
Compose de weg: de waarde staat dan in een bestand dat de container zelf inleest.
Dat vereist wel dat het image een `*_FILE`-variant kent. Postgres, MariaDB en
MySQL kunnen het; de meeste apps niet.
Een externe wachtwoordkluis (Vault, Infisical) raden we hier af: Server Up zou
dan afhangen van een app die het zelf beheert, en als die niet draait start er
niets meer.
## Wat níét is afgedekt ## Wat níét is afgedekt
- **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat - **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 is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints
toelaat werkt niet, omdat compose vrijwel alles nodig heeft. 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 - **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een
proxy met rate limiting voor. proxy met rate limiting voor.

View file

@ -741,10 +741,73 @@ def api_stack_env_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_env(d, (request.json or {}).get("content", "")) inhoud = (request.json or {}).get("content", "")
# Compose leest dit bestand regel voor regel; een regel zonder '=' wordt
# stilzwijgend genegeerd en dan mist er ineens een wachtwoord.
for nr, regel in enumerate(inhoud.splitlines(), 1):
kaal = regel.strip()
if not kaal or kaal.startswith("#"):
continue
if "=" not in kaal:
return jsonify(ok=False,
msg=f"Regel {nr} heeft geen '=': {kaal[:60]}"), 400
docker.write_env(d, inhoud)
audit.log("stacks", "env_edit", "ok", ref=name, ip=request.remote_addr)
return jsonify(ok=True) return jsonify(ok=True)
@app.route("/api/stacks/<name>/credentials")
def api_stack_credentials(name):
"""Waarmee log je bij deze app in?
De wachtwoorden zijn vaak door Server Up gegenereerd; zonder deze route kun
je ze nergens meer terugvinden zonder het compose-bestand of `.env` open te
slaan.
"""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
if auth.role_of(auth.current_user() or "") != "admin":
return jsonify(ok=False, msg="Alleen een beheerder mag inloggegevens zien"), 403
meta = _stack_meta_lezen(d)
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
velden = boilerplates.fields(bron) if bron else []
waarden = {**(meta.get("values") or {}), **_geheimen_uit_env(d, meta)}
items = []
for f in velden:
soort = f.get("credential")
if not soort:
continue
waarde = str(waarden.get(f["name"], "") or "")
if not waarde:
continue
items.append({"kind": soort, "title": f.get("title") or f["name"],
"value": waarde, "secret": bool(f.get("secret"))})
# Het adres waarop de app draait: eigen IP, anders de gepubliceerde poort.
adres = ""
try:
poorten = compose_transform.published_ports(docker.read_compose(d))
except Exception:
poorten = []
if meta.get("ip"):
adres = f"http://{meta['ip']}"
elif poorten:
eerste = str(poorten[0]).strip('"').split(":")
if len(eerste) >= 2 and eerste[-2].isdigit():
adres = f"http://{request.host.split(':')[0]}:{eerste[-2]}"
if items:
audit.log("stacks", "credentials_view", "ok", ref=name,
ip=request.remote_addr)
return jsonify(ok=True, address=adres, items=items,
# Apps als Vaultwarden en Immich maken het eerste account aan
# bij het eerste bezoek; dan valt er hier niets te tonen.
self_signup=not items)
@app.route("/api/stacks/<name>/compose") @app.route("/api/stacks/<name>/compose")
def api_stack_compose(name): def api_stack_compose(name):
lib, d = _stack_dir(name) lib, d = _stack_dir(name)
@ -782,8 +845,49 @@ def _stack_meta_lezen(d: Path) -> dict:
def _stack_meta_schrijven(d: Path, updates: dict): def _stack_meta_schrijven(d: Path, updates: dict):
meta = _stack_meta_lezen(d) meta = _stack_meta_lezen(d)
meta.update(updates) meta.update(updates)
(d / ".serverup.json").write_text( mf = d / ".serverup.json"
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") mf.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
# Hier stond tot v0.7.60 elk wachtwoord in, wereldleesbaar. De geheimen zijn
# nu naar .env verhuisd, maar de rechten horen hoe dan ook beperkt.
try:
mf.chmod(0o600)
except OSError:
pass
def _splits_geheimen(velden: list[dict], waarden: dict) -> tuple[dict, list[str]]:
"""Scheid de geheime waarden van de rest.
Wat overblijft gaat naar `.serverup.json` voor het herconfiguratieformulier;
de geheimen staan in `.env` en worden daar weer opgehaald.
"""
geheim = {f["name"] for f in velden if f.get("secret")}
zonder = {k: v for k, v in (waarden or {}).items() if k not in geheim}
return zonder, sorted(n for n in geheim if str((waarden or {}).get(n, "")).strip())
def _env_lezen(d: Path) -> dict:
"""`.env` als dictionary."""
env = {}
for regel in docker.read_env(d).splitlines():
if "=" in regel and not regel.lstrip().startswith("#"):
sleutel, _, waarde = regel.partition("=")
env[sleutel.strip()] = waarde
return env
def _geheimen_uit_env(d: Path, meta: dict) -> dict:
"""De geheime waarden terug uit `.env`, via de bewaarde koppeling.
`secret_keys` staat in `.serverup.json` en zegt precies welk veld bij welke
`.env`-sleutel hoort, zodat we niet hoeven te raden.
"""
koppeling = meta.get("secret_keys") or {}
if not koppeling:
return {}
env = _env_lezen(d)
return {veld: env[sleutel] for veld, sleutel in koppeling.items()
if sleutel in env}
@app.route("/api/stacks/<name>/config") @app.route("/api/stacks/<name>/config")
@ -808,12 +912,16 @@ def api_stack_config(name):
msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte " msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte "
"keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. " "keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. "
"Wijzigen kan via 'compose bewerken'.")) "Wijzigen kan via 'compose bewerken'."))
# De geheimen staan niet in .serverup.json maar in .env; zonder dit zou het
# formulier ze leeg tonen en zou opslaan ze wissen.
waarden = {**waarden, **_geheimen_uit_env(d, meta)}
return jsonify(ok=True, editable=True, return jsonify(ok=True, editable=True,
fields=_annotate_fields(boilerplates.fields(bron)), fields=_annotate_fields(boilerplates.fields(bron)),
values=waarden, values=waarden,
network=meta.get("network", ""), ip=meta.get("ip", ""), network=meta.get("network", ""), ip=meta.get("ip", ""),
connected=bool(meta.get("connected")), connected=bool(meta.get("connected")),
source=meta.get("source", ""), name=meta.get("name", name)) source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name))
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"]) @app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
@ -867,6 +975,22 @@ def api_stack_reconfigure(name):
jobs.log(qq, "ok", "Opnieuw gerenderd") jobs.log(qq, "ok", "Opnieuw gerenderd")
cf = docker.find_compose(d) cf = docker.find_compose(d)
# Ook hier de geheimen uit het compose-bestand halen, anders staan
# ze na een wijziging alsnog leesbaar in de stackmap.
velden_bron = boilerplates.fields(bron)
secret_keys = {}
if cf:
try:
nieuw_txt, env_geheim, secret_keys = compose_transform.geheimen_naar_env(
cf.read_text(encoding="utf-8"), velden_bron, nieuwe_waarden)
if env_geheim:
cf.write_text(nieuw_txt, encoding="utf-8")
docker.write_env(d, compose_transform.env_samenvoegen(
docker.read_env(d), env_geheim))
jobs.log(qq, "ok",
f"{len(env_geheim)} geheim(en) in .env bijgewerkt")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Geheimen niet verplaatst: {e}")
if net_name: if net_name:
net = _network_by_name(net_name) net = _network_by_name(net_name)
if net and net_ip: if net and net_ip:
@ -888,8 +1012,11 @@ def api_stack_reconfigure(name):
backups.restore(name, backup_naam, log_fn=lf) backups.restore(name, backup_naam, log_fn=lf)
jobs.finish(jid, "error"); jobs.done(qq); return jobs.finish(jid, "error"); jobs.done(qq); return
zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden)
_stack_meta_schrijven(d, { _stack_meta_schrijven(d, {
"values": nieuwe_waarden, "network": net_name, "ip": net_ip, "values": zichtbaar, "secret_fields": geheime_namen,
"secret_keys": secret_keys,
"network": net_name, "ip": net_ip,
"connected": bool(verbinden and gedeeld_net)}) "connected": bool(verbinden and gedeeld_net)})
docker.compose_down(d, log_fn=lf, name=name) docker.compose_down(d, log_fn=lf, name=name)
@ -1204,15 +1331,29 @@ def api_store_preview():
if boilerplates.is_boilerplate(src): if boilerplates.is_boilerplate(src):
meta = boilerplates.metadata(src) meta = boilerplates.metadata(src)
flds = _annotate_fields(boilerplates.fields(src)) flds = _annotate_fields(boilerplates.fields(src))
# Render een preview met defaults, zodat de gebruiker ziet wat er komt # Render met de waarden die de gebruiker tot nu toe heeft gekozen, zodat
# de laatste stap van het invulmenu laat zien wat er écht komt te staan.
# Zonder waarden (bij het openen) vallen we terug op de standaarden.
waarden = dict(d.get("values") or {})
env_preview = ""
try: try:
ctx = boilerplates.build_context(src, {}) ctx = boilerplates.build_context(src, waarden)
files_dir = src / "files" files_dir = src / "files"
preview_files = {} preview_files = {}
for p in sorted(files_dir.rglob("*")): for p in sorted(files_dir.rglob("*")):
if p.is_file() and p.suffix.lower() in (".yml", ".yaml") and p.stat().st_size < 32_000: if p.is_file() and p.suffix.lower() in (".yml", ".yaml") and p.stat().st_size < 32_000:
preview_files[p.relative_to(files_dir).as_posix()] = \ preview_files[p.relative_to(files_dir).as_posix()] = \
boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:4000] boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:8000]
# Dezelfde bewerking als bij het installeren, anders toont de
# voorvertoning iets anders dan er straks op schijf komt.
sleutel = next((k for k in ("compose.yaml", "docker-compose.yml",
"compose.yml") if k in preview_files), "")
if sleutel and waarden:
nieuw, env_geheim, _ = compose_transform.geheimen_naar_env(
preview_files[sleutel], flds, waarden)
if env_geheim:
preview_files[sleutel] = nieuw
env_preview = "".join(f"{k}={v}\n" for k, v in env_geheim.items())
except Exception as e: except Exception as e:
preview_files = {"_error": f"render: {e}"} preview_files = {"_error": f"render: {e}"}
return jsonify( return jsonify(
@ -1227,7 +1368,7 @@ def api_store_preview():
env_vars=[], images=[], ports=[], env_vars=[], images=[], ports=[],
compose_preview=preview_files.get("compose.yaml", "") or compose_preview=preview_files.get("compose.yaml", "") or
preview_files.get("docker-compose.yml", ""), preview_files.get("docker-compose.yml", ""),
env_preview="", env_preview=env_preview,
) )
# ── Legacy Server Up formaat (compose.yml + .env) ───────────────────────── # ── Legacy Server Up formaat (compose.yml + .env) ─────────────────────────
@ -1318,6 +1459,9 @@ 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
# Handmatig bewerkt in de laatste stap van het invulmenu.
compose_override = str(d.get("compose_override") or "")
env_override = str(d.get("env_override") or "")
net_name = (d.get("network") or "").strip() # leeg = poorten op de host net_name = (d.get("network") or "").strip() # leeg = poorten op de host
net_ip = (d.get("ip") or "").strip() net_ip = (d.get("ip") or "").strip()
# Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam # Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam
@ -1366,6 +1510,14 @@ def api_store_install():
return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt), return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt),
missing=ontbreekt), 400 missing=ontbreekt), 400
# Een zelf bewerkt compose-bestand eerst controleren: een typefout hoort hier
# te stranden, niet halverwege een installatie die al een map heeft gemaakt.
if compose_override.strip():
try:
yaml.safe_load(compose_override)
except yaml.YAMLError as e:
return jsonify(ok=False, msg=f"Het compose-bestand is geen geldige YAML:\n{e}"), 400
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
if lib == Path("/app").resolve(): if lib == Path("/app").resolve():
return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.") return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.")
@ -1383,6 +1535,12 @@ def api_store_install():
def _run(jid): def _run(jid):
qq = jobs.get_queue(jid) qq = jobs.get_queue(jid)
secret_keys: dict = {}
velden_bron: list = []
# De waarden zoals er echt mee gerenderd is, inclusief de defaults die
# de client niet meestuurde. Alleen die zijn bruikbaar om het formulier
# later opnieuw te openen.
gebruikte_waarden: dict = {}
try: try:
jobs.log(qq, "section", f"Installeren: {inst}") jobs.log(qq, "section", f"Installeren: {inst}")
try: try:
@ -1396,10 +1554,17 @@ def api_store_install():
values = dict(bp_values or {}) values = dict(bp_values or {})
# Defaults voor service_name als de gebruiker niets opgaf # Defaults voor service_name als de gebruiker niets opgaf
values.setdefault("service_name", inst) values.setdefault("service_name", inst)
# Velden die de client niet meestuurde krijgen alsnog hun
# standaardwaarde, zodat het formulier later compleet is.
for _f in boilerplates.fields(src):
if _f["name"] not in values and _f.get("default") is not None:
values[_f["name"]] = _f["default"]
gebruikte_waarden = values
jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)") jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)")
# Wachtwoorden en sleutels niet uitschrijven: dit log staat in # Wachtwoorden en sleutels niet uitschrijven: dit log staat in
# de interface en wordt vaak gekopieerd in een bugmelding. # de interface en wordt vaak gekopieerd in een bugmelding.
geheim = {f["name"] for f in boilerplates.fields(src) if f.get("secret")} velden_bron = boilerplates.fields(src)
geheim = {f["name"] for f in velden_bron if f.get("secret")}
for k, v in sorted(values.items()): for k, v in sorted(values.items()):
jobs.log(qq, "dim", f" {k}=" + ("••••••" if k in geheim and v else str(v))) jobs.log(qq, "dim", f" {k}=" + ("••••••" if k in geheim and v else str(v)))
try: try:
@ -1410,6 +1575,33 @@ def api_store_install():
except boilerplates.BoilerplateError as e: except boilerplates.BoilerplateError as e:
jobs.log(qq, "error", str(e)) jobs.log(qq, "error", str(e))
jobs.finish(jid, "error"); jobs.done(qq); return jobs.finish(jid, "error"); jobs.done(qq); return
# Wachtwoorden en sleutels naar .env, zodat het compose-bestand
# te delen is en de geheimen op één plek met 0600 staan.
cf_render = docker.find_compose(dest)
if cf_render:
try:
nieuw, env_geheim, koppeling = compose_transform.geheimen_naar_env(
cf_render.read_text(encoding="utf-8"), velden_bron, values)
if env_geheim:
cf_render.write_text(nieuw, encoding="utf-8")
docker.write_env(dest, compose_transform.env_samenvoegen(
docker.read_env(dest), env_geheim))
secret_keys = koppeling
jobs.log(qq, "ok",
f"{len(env_geheim)} geheim(en) naar .env verplaatst")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Geheimen niet verplaatst: {e}")
# Heb je het resultaat in de laatste stap zelf aangepast, dan
# telt jouw versie — het renderen hierboven levert dan alleen de
# overige bestanden.
if compose_override.strip() and cf_render:
cf_render.write_text(compose_override, encoding="utf-8")
jobs.log(qq, "ok", "Eigen compose-bestand geschreven")
if env_override.strip():
docker.write_env(dest, env_override)
jobs.log(qq, "ok", "Eigen .env geschreven")
else: else:
# ── Legacy: copy verbatim ───────────────────────────────────── # ── Legacy: copy verbatim ─────────────────────────────────────
shutil.copytree(str(src), str(dest), dirs_exist_ok=True) shutil.copytree(str(src), str(dest), dirs_exist_ok=True)
@ -1520,7 +1712,12 @@ def api_store_install():
logo = meta.get("logo_url", "") logo = meta.get("logo_url", "")
if not logo and not is_bp: if not logo and not is_bp:
logo = git.guess_logo_url(meta.get("name") or stack) logo = git.guess_logo_url(meta.get("name") or stack)
(dest / ".serverup.json").write_text(json.dumps({ # Geheimen gaan niet mee: die staan in .env. Hier blijft alleen
# staan wélke velden geheim waren en onder welke .env-sleutel de
# waarde te vinden is.
zichtbaar, geheime_namen = _splits_geheimen(
velden_bron if is_bp else [], gebruikte_waarden if is_bp else {})
_stack_meta_schrijven(dest, {
"source": stack, "source": stack,
"repo_id": rid, "repo_id": rid,
"name": meta.get("name", stack), "name": meta.get("name", stack),
@ -1536,9 +1733,11 @@ def api_store_install():
# Zonder de gebruikte waarden valt het installatieformulier # Zonder de gebruikte waarden valt het installatieformulier
# later niet opnieuw te openen om iets te wijzigen. # later niet opnieuw te openen om iets te wijzigen.
"format": "boilerplate" if is_bp else "compose", "format": "boilerplate" if is_bp else "compose",
"values": bp_values if is_bp else {}, "values": zichtbaar if is_bp else {},
"secret_fields": geheime_namen,
"secret_keys": secret_keys,
"env_overrides": {} if is_bp else env_overrides, "env_overrides": {} if is_bp else env_overrides,
}, ensure_ascii=False, indent=2), encoding="utf-8") })
except Exception: except Exception:
pass pass

View file

@ -99,6 +99,10 @@ def fields(d: Path) -> list[dict]:
"required": False, "options": [], "needs": [], "required": False, "options": [], "needs": [],
"placeholder": "", "description": grp.get("description", ""), "placeholder": "", "description": grp.get("description", ""),
"is_group_toggle": True, "is_group_toggle": True,
# De schakelaar hoort bij de stap waar zijn eigen velden staan;
# die bepalen we pas hieronder, dus voorlopig 'instellingen'.
"step": "instellingen", "credential": "",
"secret": False, "generate": "", "advanced": bool(grp.get("advanced")),
}) })
groep_geavanceerd = bool(grp.get("advanced")) groep_geavanceerd = bool(grp.get("advanced"))
for item in grp.get("items", []) or []: for item in grp.get("items", []) or []:
@ -131,12 +135,73 @@ def fields(d: Path) -> list[dict]:
"secret": bool(item.get("secret")) or _lijkt_geheim(item), "secret": bool(item.get("secret")) or _lijkt_geheim(item),
# Waarden die de gebruiker toch niet zelf kan bedenken (JWT- # Waarden die de gebruiker toch niet zelf kan bedenken (JWT-
# sleutels, database-wachtwoorden) worden voorgevuld met iets # sleutels, database-wachtwoorden) worden voorgevuld met iets
# willekeurigs. "hex32" = 32 hexadecimale tekens. # willekeurigs. "auto" = ook meteen invullen, True = alleen een
# knop (voor wachtwoorden waarmee je zelf inlogt).
"generate": item.get("generate") or "", "generate": item.get("generate") or "",
# In welke stap van het invulmenu dit veld thuishoort.
"step": _stap_van(item, toggle),
# "username" of "password": wordt getoond in het paneel
# Inloggegevens, zodat je na de installatie kunt opzoeken
# waarmee je moet inloggen.
"credential": item.get("credential") or _credential_van(item),
}) })
return out return out
# ── Indeling in het invulmenu ────────────────────────────────────────────────
# Vijf vaste stappen voor elke app. Eén stap per groep werkt niet: de mediane
# app heeft één groep en arr-stack heeft er eenentwintig.
STAPPEN = ("basis", "verbinden", "instellingen", "geheimen")
_BASIS_NAMEN = ("service_name", "data_dir", "timezone", "tz", "puid", "pgid",
"downloads_dir", "media_dir", "config_dir")
def _stap_van(item: dict, groep_toggle: str = "") -> str:
"""In welke stap hoort dit veld?
Expliciet in template.json gaat voor; anders afgeleid uit naam en type.
"""
expliciet = (item.get("step") or "").strip().lower()
if expliciet in STAPPEN:
return expliciet
naam = (item.get("name") or "").lower()
if item.get("connect"):
return "verbinden"
if item.get("secret") or _lijkt_geheim(item):
return "geheimen"
# Een gebruikersnaam hoort bij het wachtwoord waarmee je inlogt, niet ergens
# tussen de overige instellingen.
if _credential_van(item) == "username":
return "geheimen"
# Poorten horen bij de basis, maar alleen als ze ook echt een poort zijn.
if item.get("type") == "int" and "port" in naam:
return "basis"
if naam in _BASIS_NAMEN:
return "basis"
return "instellingen"
_GEBRUIKERSNAAM_DELEN = ("user", "username", "email", "login", "account")
def _credential_van(item: dict) -> str:
"""Herken inloggegevens, zodat de bestaande 96 sjablonen meteen werken."""
naam = (item.get("name") or "").lower()
if _lijkt_geheim(item) and any(w in naam for w in ("password", "passwd",
"wachtwoord", "adminpass")):
return "password"
if _lijkt_geheim(item):
return "" # tokens en sleutels zijn geen inloggegevens
delen = naam.split("_")
if any(d in _GEBRUIKERSNAAM_DELEN for d in delen) or "email" in naam:
return "username"
# Ook samenstellingen: superuser_name, adminuser, login_name.
if any(w in naam for w in ("user", "login", "email")) and "password" not in naam:
return "username"
return ""
# Namen die vrijwel altijd een geheim aanduiden. Bedoeld om te voorkomen dat een # Namen die vrijwel altijd een geheim aanduiden. Bedoeld om te voorkomen dat een
# nieuw template het per ongeluk als gewoon tekstveld toont. # nieuw template het per ongeluk als gewoon tekstveld toont.
_GEHEIM_WOORDEN = ("password", "passwd", "secret", "token", "apikey", _GEHEIM_WOORDEN = ("password", "passwd", "secret", "token", "apikey",

View file

@ -127,6 +127,121 @@ def add_shared_network(compose_text: str, network: str) -> str:
allow_unicode=True, width=120) allow_unicode=True, width=120)
def geheimen_naar_env(compose_text: str, velden: list[dict],
waarden: dict) -> tuple[str, dict[str, str], dict[str, str]]:
"""Haal wachtwoorden en sleutels uit het compose-bestand naar `.env`.
Compose vult `${NAAM}` in bij het inlezen, dus functioneel verandert er
niets maar het compose-bestand is daarna te delen en te committen, en alle
geheimen staan op één plek die we met 0600 kunnen wegschrijven.
Retourneert het aangepaste compose-bestand, de `.env`-regels, en welk veld
bij welke `.env`-sleutel hoort die koppeling bewaren we, zodat we de
waarde later exact kunnen terugvinden in plaats van te moeten raden.
De sleutelnaam komt uit de omgevingsvariabele zelf (`POSTGRES_PASSWORD`),
zodat `.env` leesbaar blijft. Staat het geheim midden in een langere waarde,
zoals een database-URL, dan wordt alleen dat stuk vervangen.
"""
# Alleen niet-lege geheimen, langste eerst: staat dezelfde waarde ook als
# deel van een andere, dan vervangen we eerst de langste en houden we de
# korte niet per ongeluk halverwege over.
geheim = sorted(
((str(waarden.get(f["name"], "")), f["name"]) for f in velden
if f.get("secret") and str(waarden.get(f["name"], "")).strip()),
key=lambda p: len(p[0]), reverse=True)
if not geheim:
return compose_text, {}, {}
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) or not isinstance(doc.get("services"), dict):
return compose_text, {}, {}
env: dict[str, str] = {}
gebruikt: dict[str, str] = {} # waarde → gekozen sleutelnaam
per_veld: dict[str, str] = {} # veldnaam → .env-sleutel
def _sleutel(voorkeur: str, veldnaam: str, waarde: str) -> str:
"""Kies een .env-naam; dezelfde waarde krijgt altijd dezelfde naam."""
if waarde in gebruikt:
# Twee velden met dezelfde waarde delen één .env-sleutel; allebei
# moeten ze wel in de koppeling staan.
per_veld.setdefault(veldnaam, gebruikt[waarde])
return gebruikt[waarde]
naam = "".join(c if c.isalnum() or c == "_" else "_"
for c in (voorkeur or veldnaam)).upper().strip("_")
if not naam or naam[0].isdigit():
naam = f"SU_{naam}"
basis, n = naam, 2
while naam in env and env[naam] != waarde:
naam, n = f"{basis}_{n}", n + 1
env[naam] = waarde
gebruikt[waarde] = naam
per_veld[veldnaam] = naam
return naam
def _vervang(tekst: str, env_sleutel: str = "") -> str:
for waarde, veldnaam in geheim:
if waarde and waarde in tekst:
naam = _sleutel(env_sleutel if tekst == waarde else "",
veldnaam, waarde)
tekst = tekst.replace(waarde, "${" + naam + "}")
return tekst
for svc in doc["services"].values():
if not isinstance(svc, dict):
continue
omgeving = svc.get("environment")
if isinstance(omgeving, dict):
for sleutel, waarde in list(omgeving.items()):
if isinstance(waarde, str):
omgeving[sleutel] = _vervang(waarde, str(sleutel))
elif isinstance(omgeving, list):
for i, regel in enumerate(omgeving):
if not isinstance(regel, str) or "=" not in regel:
continue
sleutel, _, waarde = regel.partition("=")
omgeving[i] = f"{sleutel}={_vervang(waarde, sleutel)}"
# Ook command en labels kunnen een geheim bevatten (bv. een
# database-URL die als argument wordt meegegeven).
for blok in ("command", "entrypoint", "labels"):
inhoud = svc.get(blok)
if isinstance(inhoud, str):
svc[blok] = _vervang(inhoud)
elif isinstance(inhoud, list):
svc[blok] = [_vervang(x) if isinstance(x, str) else x
for x in inhoud]
elif isinstance(inhoud, dict):
svc[blok] = {k: _vervang(v) if isinstance(v, str) else v
for k, v in inhoud.items()}
if not env:
return compose_text, {}, {}
return (yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True, width=120), env, per_veld)
def env_samenvoegen(bestaand: str, nieuw: dict[str, str]) -> str:
"""Werk `.env`-regels bij zonder de rest van het bestand te verliezen."""
regels = (bestaand or "").splitlines()
gezien = set()
uit = []
for regel in regels:
sleutel = regel.split("=", 1)[0].strip()
if sleutel in nieuw:
uit.append(f"{sleutel}={nieuw[sleutel]}")
gezien.add(sleutel)
else:
uit.append(regel)
for sleutel, waarde in nieuw.items():
if sleutel not in gezien:
uit.append(f"{sleutel}={waarde}")
return "\n".join(uit).strip("\n") + "\n"
def published_ports(compose_text: str) -> list[str]: def published_ports(compose_text: str) -> list[str]:
"""Alle host-poorten die dit compose-bestand publiceert (voor de UI).""" """Alle host-poorten die dit compose-bestand publiceert (voor de UI)."""
try: try:

View file

@ -302,8 +302,17 @@ def read_env(d: Path) -> str:
def write_env(d: Path, content: str): def write_env(d: Path, content: str):
"""Schrijf `.env` en beperk de rechten tot de eigenaar.
Hier staan de wachtwoorden en sleutels van de stack in; standaard maakt
Python het bestand wereldleesbaar.
"""
f = d / ".env" f = d / ".env"
f.write_text(content) f.write_text(content)
try:
f.chmod(0o600)
except OSError:
pass # bv. op een filesystem dat geen rechten kent
def read_compose(d: Path) -> str: def read_compose(d: Path) -> str:

View file

@ -355,6 +355,7 @@ tailwind.config = {
<button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button> <button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button> <button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button> <button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
<button class="btn-sm" :title="t('credentials')" @click="openCredentials(s.name)"><span class="mdi mdi-account-key-outline"></span></button>
<button class="btn-sm" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button> <button class="btn-sm" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
<button class="btn-sm" :title="t('stack_settings')" @click="openReconfigure(s.name)" <button class="btn-sm" :title="t('stack_settings')" @click="openReconfigure(s.name)"
:disabled="!s.editable"><span class="mdi mdi-tune"></span></button> :disabled="!s.editable"><span class="mdi mdi-tune"></span></button>
@ -1358,12 +1359,6 @@ tailwind.config = {
<span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span> <span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span>
</div> </div>
<div class="modal-body overflow-y-auto space-y-3"> <div class="modal-body overflow-y-auto space-y-3">
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
<div x-show="install.mode !== 'edit'">
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
<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>
</div>
<!-- Bij wijzigen wordt de stack opnieuw gerenderd; dat overschrijft <!-- Bij wijzigen wordt de stack opnieuw gerenderd; dat overschrijft
handmatige aanpassingen in het compose-bestand. --> handmatige aanpassingen in het compose-bestand. -->
<p x-show="install.mode === 'edit'" class="text-[11px] text-wr flex items-start gap-1.5"> <p x-show="install.mode === 'edit'" class="text-[11px] text-wr flex items-start gap-1.5">
@ -1390,7 +1385,53 @@ tailwind.config = {
</div> </div>
</div> </div>
<!-- Koppelen aan andere apps --> <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>
<!-- Invulmenu: vaste stappen, ongeacht hoeveel groepen de app heeft -->
<template x-if="install.format === 'boilerplate' && !install.loading">
<div class="space-y-3">
<!-- Stapindicator; klikbaar om terug te springen -->
<div class="flex items-center gap-1 overflow-x-auto pb-1" x-show="!onePage">
<template x-for="(s, i) in wizardSteps()" :key="s.id">
<button type="button" @click="gotoStep(i)"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] whitespace-nowrap transition"
:class="i === install.step
? 'bg-ac text-white font-bold'
: (i < install.step ? 'text-ok' : 'text-dm dark:text-dm-dark hover:text-tx')">
<span class="mdi" :class="i < install.step ? 'mdi-check-circle' : s.icon"></span>
<span x-text="s.title"></span>
</button>
</template>
</div>
<!-- Altijd bereikbaar: geavanceerde opties en alles-op-één-pagina -->
<div class="flex items-center gap-3 flex-wrap text-[11px]">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" :checked="showAdvanced"
@change="showAdvanced = $event.target.checked; saveUiKeuze()" class="sr-only peer">
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
<span class="mdi mdi-tune text-t2 dark:text-t2-dark"></span>
<span class="text-t2 dark:text-t2-dark" x-text="t('advanced_options')">Geavanceerde opties</span>
<span x-show="advancedCount() > 0" class="badge badge-dim" x-text="advancedCount()"></span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer ml-auto">
<input type="checkbox" :checked="onePage"
@change="onePage = $event.target.checked; saveUiKeuze()" class="sr-only peer">
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
<span class="text-t2 dark:text-t2-dark" x-text="t('one_page')">Alles op één pagina</span>
</label>
</div>
<!-- De stappen zelf -->
<template x-for="(s, i) in wizardSteps()" :key="s.id">
<div x-show="onePage || i === install.step" class="space-y-3">
<div x-show="onePage" class="text-[10px] font-bold tracking-wider uppercase text-ac dark:text-ac-dark pt-2"
x-text="s.title"></div>
<!-- Verbinden: netwerk en koppelingen -->
<template x-if="s.id === 'verbinden'">
<div class="space-y-3">
<label class="flex items-start gap-2.5 cursor-pointer"> <label class="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" :checked="install.connect_apps !== false" <input type="checkbox" :checked="install.connect_apps !== false"
@change="install.connect_apps = $event.target.checked" @change="install.connect_apps = $event.target.checked"
@ -1401,8 +1442,6 @@ tailwind.config = {
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span> x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
</span> </span>
</label> </label>
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
<div> <div>
<label class="form-label" x-text="t('network')">Netwerk</label> <label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()"> <select class="form-input" x-model="install.network" @change="pickIp()">
@ -1421,32 +1460,67 @@ tailwind.config = {
<p x-show="!install.network && netList.length === 0" <p x-show="!install.network && netList.length === 0"
class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p> class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
</div> </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>
</template>
<!-- Boilerplate fields --> <!-- Controleren: het gerenderde resultaat, bewerkbaar -->
<template x-if="install.format === 'boilerplate' && !install.loading"> <template x-if="s.id === 'controleren'">
<div class="space-y-2">
<div class="flex items-center gap-1">
<button type="button" class="btn-sm" :class="install.tab==='compose' ? '!bg-ac !text-white' : ''"
@click="install.tab='compose'">docker-compose.yml</button>
<button type="button" class="btn-sm" :class="install.tab==='env' ? '!bg-ac !text-white' : ''"
@click="install.tab='env'">.env</button>
<button type="button" class="btn-sm ml-auto" @click="toggleEdit()">
<span class="mdi" :class="install.editing ? 'mdi-eye-outline' : 'mdi-pencil-outline'"></span>
<span x-text="install.editing ? t('preview') : t('edit')"></span>
</button>
</div>
<p x-show="install.editing" class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span>
<span x-text="t('manual_edit_warning')"></span>
</p>
<textarea x-show="install.editing" rows="16"
class="form-input font-mono !text-[11px] leading-snug"
x-model="install.tab==='compose' ? install.composeText : install.envText"
@input="install.tab==='compose'
? install.composeText = $event.target.value
: install.envText = $event.target.value"></textarea>
<pre x-show="!install.editing"
class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-80"
x-text="(install.tab==='compose' ? install.composeText : install.envText) || t('nothing_here')"></pre>
</div>
</template>
<!-- Basis, instellingen en geheimen: de velden van die stap -->
<template x-if="!['verbinden','controleren'].includes(s.id)">
<div class="space-y-3"> <div class="space-y-3">
<template x-for="grp in groupedFields" :key="grp.name"> <div x-show="s.id === 'basis' && install.mode !== 'edit'">
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden" :open="!grp.toggle || !!install.values[grp.toggle]"> <label class="form-label" x-text="t('instance_name')">Instantie naam</label>
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60"> <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>
</div>
<template x-for="grp in stepGroups(s.id)" :key="s.id + '/' + grp.name">
<div class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<div x-show="grp.showHeader"
class="px-3.5 py-2.5 font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60">
<span x-text="grp.name"></span> <span x-text="grp.name"></span>
<template x-if="grp.toggle"> <template x-if="grp.toggle">
<label class="ml-auto inline-flex items-center cursor-pointer" @click.stop> <label class="ml-auto inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="!!install.values[grp.toggle]" @change="install.values[grp.toggle] = $event.target.checked" class="sr-only peer"> <input type="checkbox" :checked="!!install.values[grp.toggle]" @change="install.values[grp.toggle] = $event.target.checked" class="sr-only peer">
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span> <span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
</label> </label>
</template> </template>
</summary> </div>
<div class="px-3.5 py-3" x-show="!grp.toggle || install.values[grp.toggle]"> <div class="px-3.5 py-3 grid sm:grid-cols-2 gap-3"
<div class="grid sm:grid-cols-2 gap-3"> x-show="!grp.toggle || install.values[grp.toggle]">
<template x-for="f in grp.items.filter(x => !x.advanced)" :key="f.name"> <template x-for="f in grp.items" :key="f.name">
<!-- De groepsschakelaar staat al in de kop; niet nog eens tonen -->
<div x-show="!f.is_group_toggle && bpFieldVisible(f)" <div x-show="!f.is_group_toggle && bpFieldVisible(f)"
:class="f.type==='bool' ? 'sm:col-span-2' : ''"> :class="f.type==='bool' ? 'sm:col-span-2' : ''">
<label class="form-label flex items-center gap-1"> <label class="form-label flex items-center gap-1">
<span x-text="f.title"></span> <span x-text="f.title"></span>
<span x-show="f.required" class="text-er">*</span> <span x-show="f.required" class="text-er">*</span>
<!-- Info-venster met de volledige uitleg bij dit veld --> <span x-show="f.advanced" class="mdi mdi-tune text-[11px] text-dm dark:text-dm-dark" :title="t('advanced_options')"></span>
<button type="button" x-show="f.help || f.description" <button type="button" x-show="f.help || f.description"
@click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f" @click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f"
class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark" class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark"
@ -1454,7 +1528,6 @@ tailwind.config = {
<span class="mdi mdi-information-outline text-sm"></span> <span class="mdi mdi-information-outline text-sm"></span>
</button> </button>
</label> </label>
<!-- Koppeling met een al ge&#239;nstalleerde app -->
<template x-if="f.connect"> <template x-if="f.connect">
<div class="space-y-1.5"> <div class="space-y-1.5">
<select class="form-input" x-model="install.values[f.name]"> <select class="form-input" x-model="install.values[f.name]">
@ -1478,12 +1551,18 @@ tailwind.config = {
</template> </template>
<template x-if="!f.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template> <template x-if="!f.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
<template x-if="!f.connect && f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template> <template x-if="!f.connect && 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 --> <!-- Geheim veld: verborgen tekst, oog om te tonen, dobbelsteen waar een
willekeurige waarde klopt (niet bij een sleutel die ergens bij moet passen) -->
<template x-if="!f.connect && f.type === 'str' && f.secret"> <template x-if="!f.connect && f.type === 'str' && f.secret">
<div class="flex gap-2"> <div class="flex gap-2">
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder"> <input :type="revealed[f.name] ? 'text' : 'password'"
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="t('show_value')"
@click="revealed[f.name] = !revealed[f.name]">
<span class="mdi" :class="revealed[f.name] ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"></span>
</button>
<button type="button" class="btn-sm shrink-0" x-show="f.generate" :title="t('generate_value')" <button type="button" class="btn-sm shrink-0" x-show="f.generate" :title="t('generate_value')"
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button> @click="install.values[f.name] = randomSecret(); revealed[f.name] = true"><span class="mdi mdi-dice-5-outline"></span></button>
</div> </div>
</template> </template>
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1" <p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
@ -1492,81 +1571,56 @@ tailwind.config = {
</div> </div>
</template> </template>
</div> </div>
</div>
</template>
<p x-show="!stepGroups(s.id).length" class="text-[11px] text-dm dark:text-dm-dark"
x-text="t('nothing_to_choose')"></p>
</div>
</template>
</div>
</template>
<!-- Verborgen menu: alles wat je meestal met rust laat --> <!-- Wat er nog leeg is; blokkeert Volgende -->
<details x-show="grp.items.some(x => x.advanced)" <p x-show="stepMissing().length" class="text-[11px] text-wr flex items-start gap-1.5">
class="mt-3 rounded-lg border border-bd dark:border-bd-dark overflow-hidden"> <span class="mdi mdi-alert-circle-outline shrink-0"></span>
<summary class="px-3 py-2 cursor-pointer text-[11px] font-bold text-t2 dark:text-t2-dark bg-s2/40 dark:bg-s2-dark/40 flex items-center gap-1.5"> <span x-text="t('still_empty', {fields: stepMissing().join(', ')})"></span>
<span class="mdi mdi-tune"></span> </p>
<span x-text="t('advanced_options')">Geavanceerde opties</span>
<span class="badge badge-dim ml-auto" x-text="grp.items.filter(x => x.advanced).length"></span>
</summary>
<div class="px-3 py-3 grid sm:grid-cols-2 gap-3">
<template x-for="f in grp.items.filter(x => x.advanced)" :key="f.name">
<div x-show="bpFieldVisible(f)" :class="f.type==='bool' ? 'sm:col-span-2' : ''">
<label class="form-label flex items-center gap-1">
<span x-text="f.title"></span>
<span x-show="f.required" class="text-er">*</span>
<!-- Info-venster met de volledige uitleg bij dit veld -->
<button type="button" x-show="f.help || f.description"
@click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f"
class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark"
:title="t('what_does_this_do')">
<span class="mdi mdi-information-outline text-sm"></span>
</button>
</label>
<!-- Koppeling met een al ge&#239;nstalleerde app -->
<template x-if="f.connect">
<div class="space-y-1.5">
<select class="form-input" x-model="install.values[f.name]">
<template x-for="doel in connectTargets(f)" :key="doel.value">
<option :value="doel.value" x-text="doel.label"></option>
</template>
<option value="__handmatig__" x-text="t('enter_manually')">Handmatig invullen</option>
</select>
<input x-show="install.values[f.name] === '__handmatig__'"
class="form-input font-mono !text-xs" :placeholder="f.placeholder"
@input="install.values[f.name] = $event.target.value">
<p x-show="!connectTargets(f).length" class="text-[11px] text-wr"
x-text="t('no_connectable_app', {app: (f.connect || {}).app || ''})"></p>
</div>
</template>
<template x-if="!f.connect && f.type === 'enum'">
<select class="form-input" x-model="install.values[f.name]"><template x-for="opt in f.options" :key="opt"><option :value="opt" x-text="opt"></option></template></select>
</template>
<template x-if="!f.connect && f.type === 'bool'">
<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 x-if="!f.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
<template x-if="!f.connect && 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.connect && 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" x-show="f.generate" :title="t('generate_value')"
@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"
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></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>
</template>
</div>
</details>
</div>
</details>
</template>
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60"><span class="mdi mdi-eye-outline"></span> Preview</summary>
<div class="p-3"><pre class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-64" x-text="bpPreview()"></pre></div>
</details>
</div> </div>
</template> </template>
<!-- Legacy fields --> <!-- Legacy fields -->
<template x-if="install.format === 'compose' && !install.loading"> <template x-if="install.format === 'compose' && !install.loading">
<div class="space-y-4"> <div class="space-y-4">
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
<div x-show="install.mode !== 'edit'">
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
<input class="form-input" x-model="install.instance">
</div>
<label class="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" :checked="install.connect_apps !== false"
@change="install.connect_apps = $event.target.checked"
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
<span class="min-w-0">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
<span class="block text-[11px] text-dm dark:text-dm-dark"
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
</span>
</label>
<div>
<label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()">
<option value="" x-text="t('ports_on_host')">Poorten op de host</option>
<template x-for="n in netList" :key="n.name">
<option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
</template>
</select>
<template x-if="install.network">
<div class="mt-2">
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
</div>
</template>
</div>
<template x-if="install.images.length"> <template x-if="install.images.length">
<div> <div>
<div class="text-[10px] font-bold tracking-wider uppercase text-pu mb-2"><span class="mdi mdi-docker"></span> Image Tags</div> <div class="text-[10px] font-bold tracking-wider uppercase text-pu mb-2"><span class="mdi mdi-docker"></span> Image Tags</div>
@ -1594,7 +1648,15 @@ tailwind.config = {
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button> <button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="install.mode === 'edit' ? doReconfigure() : doInstall()"> <button class="btn" x-show="wizardActief() && install.step > 0" @click="prevStep()">
<span class="mdi mdi-chevron-left"></span> <span x-text="t('previous')">Vorige</span>
</button>
<button class="btn btn-primary" x-show="wizardActief() && !opLaatsteStap()"
@click="nextStep()" :disabled="stepMissing().length">
<span x-text="t('next')">Volgende</span> <span class="mdi mdi-chevron-right"></span>
</button>
<button class="btn btn-primary" x-show="!wizardActief() || opLaatsteStap()"
@click="install.mode === 'edit' ? doReconfigure() : doInstall()">
<span class="mdi" :class="install.mode === 'edit' ? 'mdi-content-save-outline' : 'mdi-rocket-launch'"></span> <span class="mdi" :class="install.mode === 'edit' ? 'mdi-content-save-outline' : 'mdi-rocket-launch'"></span>
<span x-text="install.mode === 'edit' ? t('save') : t('install')">Installeren</span> <span x-text="install.mode === 'edit' ? t('save') : t('install')">Installeren</span>
</button> </button>
@ -1616,14 +1678,75 @@ tailwind.config = {
<!-- Editor --> <!-- Editor -->
<div x-show="modal==='editor'" class="modal max-w-2xl flex flex-col max-h-[88vh]"> <div x-show="modal==='editor'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi" :class="editor.type==='env' ? 'mdi-file-cog-outline' : 'mdi-file-code-outline'"></span> <span x-text="editor.title"></span></div> <div class="modal-head">
<div class="modal-body flex-1 flex"><textarea x-model="editor.content" class="flex-1 form-input !bg-[#0b1018] !text-slate-200 !border-black/30 font-mono text-xs min-h-[300px] leading-relaxed"></textarea></div> <span class="mdi mdi-file-document-edit-outline text-ac dark:text-ac-dark"></span>
<span x-text="editor.stack"></span>
<!-- Tabbladen: tot nu toe moest je de modal sluiten en opnieuw openen om
van compose naar .env te gaan. -->
<div class="ml-auto flex items-center gap-1">
<button class="btn-sm" :class="editor.type==='compose' ? '!bg-ac !text-white' : ''"
@click="switchEditor('compose')">docker-compose.yml</button>
<button class="btn-sm" :class="editor.type==='env' ? '!bg-ac !text-white' : ''"
@click="switchEditor('env')">.env</button>
</div>
</div>
<div class="modal-body flex-1 flex flex-col gap-2">
<p x-show="editor.type==='env'" class="text-[11px] text-dm dark:text-dm-dark flex items-start gap-1.5">
<span class="mdi mdi-key-variant shrink-0"></span>
<span x-text="t('env_holds_secrets')"></span>
</p>
<textarea x-model="editor.content" class="flex-1 form-input !bg-[#0b1018] !text-slate-200 !border-black/30 font-mono text-xs min-h-[300px] leading-relaxed"></textarea>
</div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button> <button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<label class="inline-flex items-center gap-2 cursor-pointer text-[11px] mr-auto">
<input type="checkbox" :checked="editor.restart" @change="editor.restart = $event.target.checked"
class="w-4 h-4 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-t2 dark:text-t2-dark" x-text="t('restart_after_save')">Herstarten na opslaan</span>
</label>
<button class="btn btn-primary" @click="saveEditor()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button> <button class="btn btn-primary" @click="saveEditor()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
</div> </div>
</div> </div>
<!-- Inloggegevens: waarmee log je bij deze app in? -->
<div x-show="modal==='credentials'" class="modal max-w-lg flex flex-col max-h-[85vh]">
<div class="modal-head">
<span class="mdi mdi-account-key-outline text-ac dark:text-ac-dark"></span>
<span x-text="t('credentials')">Inloggegevens</span>
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="creds.stack"></span>
</div>
<div class="modal-body overflow-y-auto space-y-3">
<template x-if="creds.address">
<a :href="creds.address" target="_blank" rel="noopener"
class="flex 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 hover:border-ac">
<span class="mdi mdi-open-in-new text-ac dark:text-ac-dark"></span>
<span class="font-mono text-xs text-tx dark:text-tx-dark" x-text="creds.address"></span>
</a>
</template>
<template x-for="c in creds.items" :key="c.title">
<div class="px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<div class="text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark" x-text="c.title"></div>
<div class="flex items-center gap-2 mt-1">
<span class="font-mono text-xs text-tx dark:text-tx-dark flex-1 break-all"
x-text="(c.secret && !creds.reveal[c.title]) ? '••••••••••••' : c.value"></span>
<button class="btn-sm !h-7 !px-2" x-show="c.secret" :title="t('show_value')"
@click="creds.reveal[c.title] = !creds.reveal[c.title]">
<span class="mdi" :class="creds.reveal[c.title] ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"></span>
</button>
<button class="btn-sm !h-7 !px-2" :title="t('copy')" @click="kopieer(c.value)">
<span class="mdi mdi-content-copy"></span>
</button>
</div>
</div>
</template>
<p x-show="creds.self_signup" class="text-[11px] text-t2 dark:text-t2-dark flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('no_credentials_hint')"></span>
</p>
</div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button></div>
</div>
<!-- Logs --> <!-- Logs -->
<div x-show="modal==='logs'" class="modal max-w-3xl flex flex-col max-h-[88vh]"> <div x-show="modal==='logs'" class="modal max-w-3xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi mdi-text-box-outline"></span> <span x-text="logs.title"></span></div> <div class="modal-head"><span class="mdi mdi-text-box-outline"></span> <span x-text="logs.title"></span></div>
@ -1797,6 +1920,9 @@ function app() {
// Backups en updatestatus per stack // Backups en updatestatus per stack
bkStack: '', bkList: [], bkSettings: {}, bkDisk: {volumes: []}, bkStack: '', bkList: [], bkSettings: {}, bkDisk: {volumes: []},
notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []}, notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
// Invulmenu: keuzes die over installaties heen blijven gelden.
showAdvanced: false, onePage: false, revealed: {},
creds: {stack: '', address: '', items: [], self_signup: false, reveal: {}},
notifyTesting: false, notifyTesting: false,
stackUpd: {}, checkingUpd: false, stackUpd: {}, checkingUpd: false,
@ -1832,7 +1958,8 @@ function app() {
stack: '', repo_id: '', instance: '', stack: '', repo_id: '', instance: '',
format: 'compose', loading: false, format: 'compose', loading: false,
envVars: [], images: [], envOverrides: {}, imageTags: {}, envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {} fields: [], values: {}, preview_files: {},
step: 0, tab: 'compose', editing: false, composeText: '', envText: ''
}, },
editor: { stack:'', type:'env', title:'', content:'' }, editor: { stack:'', type:'env', title:'', content:'' },
logs: { title:'', content:'' }, logs: { title:'', content:'' },
@ -1850,6 +1977,7 @@ function app() {
], ],
async init() { async init() {
this.laadUiKeuzes();
this.applyThemePref(); this.applyThemePref();
if (window.matchMedia) { if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
@ -2202,16 +2330,47 @@ function app() {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode }); const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode });
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks()); if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
}, },
async openCredentials(stack) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/credentials`);
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.creds = { stack, address: d.address || '', items: d.items || [],
self_signup: !!d.self_signup, reveal: {} };
this.modal = 'credentials';
},
async kopieer(tekst) {
try {
await navigator.clipboard.writeText(tekst);
this.toast(this.t('copied'), 'ok');
} catch (e) {
// Zonder https staat de clipboard-API niet toe; dan maar selecteren.
this.toast(this.t('copy_failed'), 'er');
}
},
async openEditor(name, type) { async openEditor(name, type) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${type}`); const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${type}`);
this.editor = { stack: name, type, title: name+' · '+type, content: d.content || '' }; this.editor = { stack: name, type, title: name+' · '+type,
content: d.content || '', restart: false };
this.modal = 'editor'; this.modal = 'editor';
}, },
// Wisselen van tabblad: eerst opslaan wat je hebt getypt zou verrassend
// zijn, dus we waarschuwen alleen als er iets gewijzigd is.
async switchEditor(type) {
if (type === this.editor.type) return;
const d = await this.rpc(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${type}`);
this.editor.type = type;
this.editor.title = this.editor.stack + ' · ' + type;
this.editor.content = d.content || '';
},
async saveEditor() { async saveEditor() {
const d = await this.req(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`, const d = await this.req(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`,
{ method:'PUT', json: { content: this.editor.content } }); { method:'PUT', json: { content: this.editor.content } });
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; } if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
else this.toast(d.msg || 'Fout bij opslaan', 'er'); this.toast(this.t('saved'), 'ok');
if (this.editor.restart) {
// Een gewijzigde compose of .env doet pas iets na een herstart.
await this.stackAction(this.editor.stack, 'restart');
}
this.modal = null;
}, },
// ── Backups ────────────────────────────────────────────────────────────── // ── Backups ──────────────────────────────────────────────────────────────
async openBackups(stack) { async openBackups(stack) {
@ -2515,6 +2674,7 @@ function app() {
loading: true, loading: true,
envVars: [], images: [], envOverrides: {}, imageTags: {}, envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {}, fields: [], values: {}, preview_files: {},
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
network: '', ip: '' network: '', ip: ''
}; };
this.modal = 'install'; this.modal = 'install';
@ -2551,6 +2711,8 @@ function app() {
} }
this.install.values = init; this.install.values = init;
this.install.preview_files = d.preview_files || {}; this.install.preview_files = d.preview_files || {};
this.install.step = 0;
this.revealed = {};
} else { } else {
this.install.envVars = d.env_vars || []; this.install.envVars = d.env_vars || [];
this.install.images = d.images || []; this.install.images = d.images || [];
@ -2565,6 +2727,104 @@ function app() {
} }
return Array.from(map.values()); return Array.from(map.values());
}, },
// ── Invulmenu ────────────────────────────────────────────────────────────
// Vijf vaste stappen voor elke app. Eén stap per groep werkt niet: de
// mediane app heeft één groep, arr-stack heeft er eenentwintig.
STAP_INFO: {
basis: { icon: 'mdi-tune-variant', key: 'step_basics' },
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
},
wizardActief() {
return this.install.format === 'boilerplate' && !this.install.loading && !this.onePage;
},
// Velden van één stap, met de geavanceerde erbij of niet.
stepFields(stap) {
return (this.install.fields || []).filter(f =>
(f.step || 'instellingen') === stap && (this.showAdvanced || !f.advanced));
},
advancedCount() {
return (this.install.fields || []).filter(f => f.advanced).length;
},
// Een lege stap slaan we over; anders klik je door schermen zonder inhoud.
wizardSteps() {
const uit = [];
for (const id of ['basis', 'verbinden', 'instellingen', 'geheimen']) {
if (id === 'verbinden') { uit.push(this._stap(id)); continue; }
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
}
uit.push(this._stap('controleren'));
return uit;
},
_stap(id) {
const info = this.STAP_INFO[id];
return { id, icon: info.icon, title: this.t(info.key) };
},
stepGroups(stap) {
const map = new Map();
for (const f of this.stepFields(stap)) {
const g = f.group || 'Algemeen';
if (!map.has(g)) map.set(g, { name: g, toggle: f.group_toggle || null, items: [] });
map.get(g).items.push(f);
}
const groepen = Array.from(map.values());
// Eén naamloze groep hoeft geen kop; dat is bij de meeste apps het geval.
for (const g of groepen) g.showHeader = groepen.length > 1 || !!g.toggle;
return groepen;
},
// Verplichte velden van de huidige stap die nog leeg zijn.
stepMissing() {
if (!this.wizardActief()) return [];
const s = this.wizardSteps()[this.install.step];
if (!s || s.id === 'controleren' || s.id === 'verbinden') return [];
return this.stepFields(s.id).filter(f => {
if (!f.required || f.is_group_toggle) return false;
if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
if (!this.bpFieldVisible(f)) return false;
const v = this.install.values[f.name];
return v === undefined || v === null || String(v).trim() === '';
}).map(f => f.title);
},
opLaatsteStap() {
return this.install.step >= this.wizardSteps().length - 1;
},
async gotoStep(i) {
const stappen = this.wizardSteps();
this.install.step = Math.max(0, Math.min(i, stappen.length - 1));
this.fieldInfo = null;
if (stappen[this.install.step].id === 'controleren') await this.refreshPreview();
},
async nextStep() {
if (this.stepMissing().length) return;
await this.gotoStep(this.install.step + 1);
},
prevStep() { this.gotoStep(this.install.step - 1); },
// Het gerenderde resultaat ophalen met de waarden die nu ingevuld staan.
async refreshPreview() {
if (this.install.editing) return; // niet overschrijven wat je zelf typte
const d = await this.rpc('/api/store/preview', {
stack: this.install.stack, repo_id: this.install.repo_id,
values: this.install.values,
});
if (!d || !d.ok) return;
this.install.preview_files = d.preview_files || {};
this.install.composeText = this.bpPreview();
this.install.envText = d.env_preview || '';
},
toggleEdit() {
this.install.editing = !this.install.editing;
if (this.install.editing) this.toast(this.t('manual_edit_warning'), 'in');
},
saveUiKeuze() {
try {
localStorage.setItem('su_advanced', this.showAdvanced ? '1' : '0');
localStorage.setItem('su_onepage', this.onePage ? '1' : '0');
} catch (e) { /* privémodus: dan onthouden we het gewoon niet */ }
},
bpFieldVisible(f) { bpFieldVisible(f) {
for (const cond of (f.needs || [])) { for (const cond of (f.needs || [])) {
const m = cond.match(/^(\w+)\s*(==|!=|=)\s*(.+)$/); const m = cond.match(/^(\w+)\s*(==|!=|=)\s*(.+)$/);
@ -2596,8 +2856,12 @@ function app() {
envVars: [], images: [], envOverrides: {}, imageTags: {}, envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: d.fields || [], values: { ...(d.values || {}) }, preview_files: {}, fields: d.fields || [], values: { ...(d.values || {}) }, preview_files: {},
network: d.network || '', ip: d.ip || '', connect_apps: !!d.connected, network: d.network || '', ip: d.ip || '', connect_apps: !!d.connected,
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
repo_id: d.repo_id || '',
}; };
this.fieldInfo = null; this.fieldInfo = null;
this.revealed = {};
this.modal = 'install'; this.modal = 'install';
}, },
async doReconfigure() { async doReconfigure() {
@ -2633,6 +2897,12 @@ function app() {
body.connect_apps = this.install.connect_apps !== false; body.connect_apps = this.install.connect_apps !== false;
if (this.install.format === 'boilerplate') body.values = this.install.values; if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; } else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
// versie in plaats van wat er uit het sjabloon zou rollen.
if (this.install.editing) {
body.compose_override = this.install.composeText;
body.env_override = this.install.envText;
}
const d = await this.rpc('/api/store/install', body); const d = await this.rpc('/api/store/install', body);
if (d.job_id) { if (d.job_id) {
this.modal = null; this.modal = null;
@ -2677,6 +2947,13 @@ function app() {
this.loadAudit(); this.loadAudit();
}, },
// Voorkeuren die over installaties heen gelden.
laadUiKeuzes() {
try {
this.showAdvanced = localStorage.getItem('su_advanced') === '1';
this.onePage = localStorage.getItem('su_onepage') === '1';
} catch (e) { /* privémodus */ }
},
async loadSettings() { async loadSettings() {
const d = await this.rpc('/api/settings'); this.settings = d || {}; const d = await this.rpc('/api/settings'); this.settings = d || {};
await this.loadAppStore(); await this.loadAppStore();

View file

@ -297,5 +297,25 @@
"notify_smtp_user": "Username", "notify_smtp_user": "Username",
"notify_smtp_password": "Password", "notify_smtp_password": "Password",
"notify_email_from": "From", "notify_email_from": "From",
"notify_email_to": "To" "notify_email_to": "To",
"copied": "Copied",
"copy": "Copy",
"copy_failed": "Could not copy — select the text yourself",
"credentials": "Credentials",
"env_holds_secrets": "This holds the passwords and keys for this app. The file is readable by its owner only.",
"manual_edit_warning": "You are editing the result yourself. If you change a field after this, your edit will be overwritten.",
"no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.",
"nothing_here": "(empty)",
"nothing_to_choose": "Nothing to configure here.",
"one_page": "All on one page",
"preview": "View",
"previous": "Previous",
"restart_after_save": "Restart after saving",
"show_value": "Show value",
"still_empty": "Not filled in yet: {fields}",
"step_basics": "Basics",
"step_network": "Connect",
"step_settings": "Settings",
"step_secrets": "Access",
"step_review": "Review"
} }

View file

@ -297,5 +297,25 @@
"notify_smtp_user": "Gebruikersnaam", "notify_smtp_user": "Gebruikersnaam",
"notify_smtp_password": "Wachtwoord", "notify_smtp_password": "Wachtwoord",
"notify_email_from": "Afzender", "notify_email_from": "Afzender",
"notify_email_to": "Ontvanger" "notify_email_to": "Ontvanger",
"copied": "Gekopieerd",
"copy": "Kopiëren",
"copy_failed": "Kopiëren lukte niet — selecteer de tekst zelf",
"credentials": "Inloggegevens",
"env_holds_secrets": "Hier staan de wachtwoorden en sleutels van deze app. Het bestand is alleen leesbaar voor de eigenaar.",
"manual_edit_warning": "Je bewerkt het resultaat zelf. Wijzig je hierna nog een veld, dan wordt je aanpassing overschreven.",
"no_credentials_hint": "Deze app heeft geen vooraf ingestelde inloggegevens: je maakt bij het eerste bezoek zelf een account aan.",
"nothing_here": "(leeg)",
"nothing_to_choose": "Hier valt niets in te stellen.",
"one_page": "Alles op één pagina",
"preview": "Bekijken",
"previous": "Vorige",
"restart_after_save": "Herstarten na opslaan",
"show_value": "Waarde tonen",
"still_empty": "Nog niet ingevuld: {fields}",
"step_basics": "Basis",
"step_network": "Verbinden",
"step_settings": "Instellingen",
"step_secrets": "Toegang",
"step_review": "Controleren"
} }

96
tests/js/menu.test.js Normal file
View file

@ -0,0 +1,96 @@
// Draait de echte Alpine-component uit index.html en toetst de logica van
// het invulmenu. Wordt aangeroepen door tests/test_ui_logica.py; los draaien
// kan met: node tests/js/menu.test.js
const fs = require('fs');
const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8');
// Alleen de component-functie uitsnijden en uitvoeren.
const start = html.indexOf('function app() {');
const eind = html.indexOf('</script>', start);
const bron = html.slice(start, eind);
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = { hash: '', search: '' };
global.window = { matchMedia: () => ({matches:false, addEventListener(){}}),
addEventListener(){}, innerWidth: 1200 };
global.document = { documentElement: {classList:{add(){},remove(){}}},
addEventListener(){}, querySelector: () => null };
global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' };
global.crypto = require('crypto').webcrypto;
eval(bron);
const c = app();
c.t = (k, v) => k; // vertaling niet nodig voor de logica
c.bpFieldVisible = () => true;
c.rpc = async () => ({ok: true, preview_files: {}, env_preview: ''});
c.toast = () => {};
function veld(naam, stap, extra = {}) {
return Object.assign({name: naam, title: naam, step: stap, type: 'str',
group: 'Algemeen', required: false, advanced: false}, extra);
}
let fouten = 0;
const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); }
else console.log(' ✓ ' + naam); };
// ── Eenvoudige app: alleen basisvelden ──────────────────────────────────────
c.install = {format:'boilerplate', loading:false, fields:[
veld('service_name','basis'), veld('port','basis'), veld('data_dir','basis'),
], values:{}, step:0};
c.onePage = false; c.showAdvanced = false;
let s = c.wizardSteps().map(x => x.id);
check('eenvoudige app: basis, verbinden, controleren',
JSON.stringify(s) === '["basis","verbinden","controleren"]');
// ── App met geheimen ────────────────────────────────────────────────────────
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
s = c.wizardSteps().map(x => x.id);
check('geheimenstap verschijnt', s.includes('geheimen'));
// ── Lege stap wordt overgeslagen ────────────────────────────────────────────
check('geen lege instellingenstap', !s.includes('instellingen'));
// ── Geavanceerde velden ─────────────────────────────────────────────────────
c.install.fields.push(veld('retention','instellingen',{advanced:true}));
check('geavanceerd veld verborgen', c.stepFields('instellingen').length === 0);
check('stap zonder zichtbare velden verdwijnt',
!c.wizardSteps().map(x=>x.id).includes('instellingen'));
c.showAdvanced = true;
check('geavanceerd veld zichtbaar na aanzetten', c.stepFields('instellingen').length === 1);
check('stap komt terug', c.wizardSteps().map(x=>x.id).includes('instellingen'));
check('teller klopt', c.advancedCount() === 1);
// ── Verplicht veld blokkeert Volgende ───────────────────────────────────────
c.install.step = c.wizardSteps().findIndex(x => x.id === 'geheimen');
check('leeg verplicht veld wordt gemeld', c.stepMissing().length === 1);
c.install.values.db_password = 'ietsGeheims';
check('ingevuld: niets meer te melden', c.stepMissing().length === 0);
// ── Groepsschakelaar uit: veld telt niet mee ────────────────────────────────
c.install.fields.push(veld('opt_pass','geheimen',
{required:true, secret:true, group:'Extra', group_toggle:'gebruik_extra'}));
c.install.values.gebruik_extra = false;
check('uitgeschakelde groep blokkeert niet', c.stepMissing().length === 0);
c.install.values.gebruik_extra = true;
check('ingeschakelde groep blokkeert wel', c.stepMissing().length === 1);
// ── Navigatie ───────────────────────────────────────────────────────────────
c.install.values.opt_pass = 'x';
c.install.step = 0;
check('niet op de laatste stap', !c.opLaatsteStap());
c.install.step = c.wizardSteps().length - 1;
check('wel op de laatste stap', c.opLaatsteStap());
check('laatste stap is controleren',
c.wizardSteps()[c.install.step].id === 'controleren');
// ── Alles op één pagina ─────────────────────────────────────────────────────
c.onePage = true;
check('wizard uit bij één pagina', !c.wizardActief());
// ── Groepskoppen ────────────────────────────────────────────────────────────
c.onePage = false;
c.install.fields = [veld('a','basis'), veld('b','basis')];
check('één groep: geen kop', c.stepGroups('basis')[0].showHeader === false);
c.install.fields.push(veld('c','basis',{group:'Tweede'}));
check('twee groepen: wel koppen', c.stepGroups('basis').every(g => g.showHeader));
console.log(fouten === 0 ? '\nAlle menucontroles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);

239
tests/test_geheimen_env.py Normal file
View file

@ -0,0 +1,239 @@
"""Geheimen horen in `.env`, niet in het compose-bestand.
Compose vult `${NAAM}` in bij het inlezen, dus functioneel verandert er niets.
Wat je wint: het compose-bestand is te delen en te committen, en alle geheimen
staan op één plek die met 0600 weggeschreven kan worden tot v0.7.60 stonden
ze zowel in `docker-compose.yml` als wereldleesbaar in `.serverup.json`.
"""
import json
import stat
import sys
import time
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
VELDEN = [{"name": "secret_key", "secret": True},
{"name": "db_password", "secret": True},
{"name": "timezone", "secret": False},
{"name": "port", "secret": False}]
WAARDEN = {"secret_key": "Xq2mB7vK9pL4nR8t", "db_password": "aB3dE6gH9jK2mN5p",
"timezone": "Europe/Amsterdam", "port": 8000}
COMPOSE = """services:
app:
image: voorbeeld:latest
ports:
- "8000:8000"
environment:
- APP_SECRET_KEY=Xq2mB7vK9pL4nR8t
- APP_DBPASS=aB3dE6gH9jK2mN5p
- TZ=Europe/Amsterdam
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: aB3dE6gH9jK2mN5p
worker:
image: worker:latest
environment:
- DATABASE_URL=postgres://app:aB3dE6gH9jK2mN5p@db:5432/app
"""
@pytest.fixture
def ct(env):
from core import compose_transform
return compose_transform
# ── De transformatie ─────────────────────────────────────────────────────────
def test_geheimen_verdwijnen_uit_compose(ct):
nieuw, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert "Xq2mB7vK9pL4nR8t" not in nieuw
assert "aB3dE6gH9jK2mN5p" not in nieuw
assert set(env_regels.values()) == {"Xq2mB7vK9pL4nR8t", "aB3dE6gH9jK2mN5p"}
def test_niet_geheime_waarden_blijven_leesbaar(ct):
"""Poorten, paden en tijdzone horen zichtbaar te blijven; anders moet je
twee bestanden naast elkaar lezen om te snappen wat er draait."""
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert "Europe/Amsterdam" in nieuw
assert "8000" in nieuw
def test_sleutelnaam_komt_uit_de_omgevingsvariabele(ct):
"""`.env` moet leesbaar zijn: APP_SECRET_KEY zegt meer dan SECRET_KEY_1."""
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert "APP_SECRET_KEY" in env_regels
def test_dezelfde_waarde_krijgt_een_sleutel(ct):
"""Het databasewachtwoord staat op drie plekken; dat wordt één regel."""
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert list(env_regels.values()).count("aB3dE6gH9jK2mN5p") == 1
def test_geheim_midden_in_een_url(ct):
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
doc = yaml.safe_load(nieuw)
url = doc["services"]["worker"]["environment"][0]
assert url.startswith("DATABASE_URL=postgres://app:${")
assert url.endswith("@db:5432/app")
def test_koppeling_wijst_veld_naar_sleutel(ct):
"""Zonder die koppeling kunnen we de waarde later niet exact terugvinden."""
_, env_regels, koppeling = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert set(koppeling) == {"secret_key", "db_password"}
for veld, sleutel in koppeling.items():
assert env_regels[sleutel] == WAARDEN[veld]
def test_zonder_geheimen_verandert_er_niets(ct):
nieuw, env_regels, koppeling = ct.geheimen_naar_env(
COMPOSE, [{"name": "port", "secret": False}], {"port": 8000})
assert nieuw == COMPOSE
assert env_regels == {} and koppeling == {}
def test_leeg_geheim_wordt_overgeslagen(ct):
"""Anders zou een lege waarde overal in het bestand 'gevonden' worden."""
nieuw, env_regels, _ = ct.geheimen_naar_env(
COMPOSE, [{"name": "leeg", "secret": True}], {"leeg": ""})
assert nieuw == COMPOSE and env_regels == {}
def test_ongeldige_yaml_geeft_een_nette_fout(ct):
with pytest.raises(ct.TransformError):
ct.geheimen_naar_env("dit: [is: geen: yaml", VELDEN, WAARDEN)
def test_compose_blijft_geldig(ct):
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
doc = yaml.safe_load(nieuw)
assert set(doc["services"]) == {"app", "db", "worker"}
# ── env samenvoegen ──────────────────────────────────────────────────────────
def test_env_samenvoegen_behoudt_bestaande_regels(ct):
uit = ct.env_samenvoegen("BIND=0.0.0.0\nPORT=5000\n", {"DB_PASS": "x"})
assert "BIND=0.0.0.0" in uit and "PORT=5000" in uit and "DB_PASS=x" in uit
def test_env_samenvoegen_vervangt_zonder_te_verdubbelen(ct):
uit = ct.env_samenvoegen("DB_PASS=oud\nPORT=5000\n", {"DB_PASS": "nieuw"})
assert uit.count("DB_PASS=") == 1
assert "DB_PASS=nieuw" in uit and "oud" not in uit
def test_env_samenvoegen_houdt_commentaar(ct):
uit = ct.env_samenvoegen("# van de installatie\nPORT=5000\n", {"X": "1"})
assert "# van de installatie" in uit
# ── Rechten ──────────────────────────────────────────────────────────────────
def test_env_wordt_met_beperkte_rechten_geschreven(env, tmp_path):
"""Hier staan de wachtwoorden in; standaard maakt Python het bestand
wereldleesbaar."""
from core import docker
docker.write_env(tmp_path, "DB_PASS=geheim\n")
modus = stat.S_IMODE((tmp_path / ".env").stat().st_mode)
assert modus == 0o600, oct(modus)
# ── Van begin tot eind ───────────────────────────────────────────────────────
def _bron(env, naam="geheimapp"):
from core import git
wortel = git.cache_dir("server-up") / "apps" / naam
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": naam},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": naam, "required": True},
{"name": "timezone", "type": "str", "title": "Tijdzone",
"default": "Europe/Amsterdam"},
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
"default": "", "required": True, "generate": "auto"},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n"
" << service_name >>:\n"
" image: nginx\n"
" environment:\n"
" - TZ=<< timezone >>\n"
" - DB_PASSWORD=<< db_password >>\n", encoding="utf-8")
return wortel
def _wacht(client, jid, tijdslimiet=10):
einde = time.time() + tijdslimiet
while time.time() < einde:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
return d
time.sleep(0.05)
return {}
def test_installatie_zet_het_geheim_in_env(client, env):
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "geheimapp", "repo_id": "server-up",
"instance": "ga", "values": {"db_password": "zeergeheim123"}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
d = env["lib"] / "ga"
compose = (d / "docker-compose.yml").read_text()
assert "zeergeheim123" not in compose, compose
assert "${" in compose
assert "zeergeheim123" in (d / ".env").read_text()
# De tijdzone hoort gewoon leesbaar te blijven.
assert "Europe/Amsterdam" in compose
def test_installatie_bewaart_geen_geheim_in_de_metadata(client, env):
"""`.serverup.json` stond wereldleesbaar vol wachtwoorden."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "geheimapp", "repo_id": "server-up",
"instance": "gb", "values": {"db_password": "zeergeheim123"}},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
mf = env["lib"] / "gb" / ".serverup.json"
ruw = mf.read_text()
assert "zeergeheim123" not in ruw, ruw
meta = json.loads(ruw)
assert meta["secret_fields"] == ["db_password"]
assert meta["secret_keys"]["db_password"] == "DB_PASSWORD"
assert stat.S_IMODE(mf.stat().st_mode) == 0o600
def test_formulier_toont_het_geheim_weer_bij_wijzigen(client, env):
"""Anders staat het veld leeg en wist opslaan het wachtwoord."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "geheimapp", "repo_id": "server-up",
"instance": "gc", "values": {"db_password": "zeergeheim123"}},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
d = client.get("/api/stacks/gc/config").get_json()
assert d["ok"] is True
assert d["values"]["db_password"] == "zeergeheim123"

340
tests/test_invulmenu.py Normal file
View file

@ -0,0 +1,340 @@
"""Het invulmenu: stapindeling, inloggegevens en zelf bewerken.
Het formulier was één lange lijst. Dat werkt slecht aan beide uiteinden: de
mediane app heeft vijf velden in één groep, arr-stack heeft er tweeënvijftig in
eenentwintig groepen. Vandaar vaste stappen in plaats van één stap per groep.
"""
import json
import stat
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
APPS = Path(__file__).resolve().parent.parent / "apps"
@pytest.fixture
def bp(env):
from core import boilerplates
return boilerplates
def _veld(bp, **kw):
"""Eén los veld door fields() halen, om de indeling te toetsen."""
from core import git
d = git.cache_dir("server-up") / "apps" / "los"
(d / "files").mkdir(parents=True, exist_ok=True)
item = {"name": "x", "type": "str", "title": "X"}
item.update(kw)
(d / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": "los"},
"variables": [{"title": "Algemeen", "items": [item]}]}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text("services:\n a:\n image: nginx\n",
encoding="utf-8")
return bp.fields(d)[0]
# ── Indeling in stappen ──────────────────────────────────────────────────────
@pytest.mark.parametrize("kw,stap", [
({"name": "service_name"}, "basis"),
({"name": "port", "type": "int"}, "basis"),
({"name": "web_port", "type": "int"}, "basis"),
({"name": "data_dir"}, "basis"),
({"name": "timezone"}, "basis"),
({"name": "db_password"}, "geheimen"),
({"name": "jwt_secret"}, "geheimen"),
({"name": "admin_user"}, "geheimen"), # hoort bij het wachtwoord
({"name": "admin_email"}, "geheimen"),
({"name": "retention"}, "instellingen"),
({"name": "allow_signup", "type": "bool"}, "instellingen"),
({"name": "broker", "connect": {"app": "mosquitto"}}, "verbinden"),
])
def test_velden_komen_in_de_juiste_stap(bp, kw, stap):
assert _veld(bp, **kw)["step"] == stap
def test_sjabloon_mag_de_stap_overrulen(bp):
"""Een app-maker moet een veld ergens anders kunnen neerzetten."""
assert _veld(bp, name="retention", step="basis")["step"] == "basis"
def test_onbekende_stap_valt_terug(bp):
assert _veld(bp, name="retention", step="verzonnen")["step"] == "instellingen"
@pytest.mark.parametrize("app,verwacht", [
("grafana", {"basis", "geheimen"}),
("vaultwarden", {"basis", "instellingen", "geheimen"}),
])
def test_echte_apps_krijgen_zinnige_stappen(bp, app, verwacht):
stappen = {f["step"] for f in bp.fields(APPS / app)}
assert stappen == verwacht
def test_de_vangnetstap_slokt_niet_alles_op(bp):
"""Een app waarvan alle velden in 'instellingen' belanden, betekent dat de
indeling niets heeft herkend. Alles in 'basis' is juist prima: de meeste
apps vragen alleen om een naam, een poort en een map."""
mislukt = []
for d in sorted(APPS.iterdir()):
if not (d / "template.json").is_file():
continue
velden = bp.fields(d)
if len(velden) > 3 and {f["step"] for f in velden} == {"instellingen"}:
mislukt.append(d.name)
assert not mislukt, mislukt
def test_eenvoudige_apps_krijgen_maar_een_paar_stappen(bp):
"""Voor de mediane app hoort het invulmenu kort te zijn."""
velden = bp.fields(APPS / "freshrss")
assert {f["step"] for f in velden} == {"basis"}
# ── Inloggegevens ────────────────────────────────────────────────────────────
@pytest.mark.parametrize("naam,soort", [
("admin_password", "password"),
("web_password", "password"),
("admin_user", "username"),
("admin_email", "username"),
("superuser_name", "username"),
("jwt_secret", ""), # een sleutel is geen inloggegeven
("api_key", ""),
("port", ""),
])
def test_inloggegevens_worden_herkend(bp, naam, soort):
assert _veld(bp, name=naam)["credential"] == soort
def test_sjabloon_mag_credential_overrulen(bp):
assert _veld(bp, name="iets", credential="username")["credential"] == "username"
def _bron(env, naam="inlogapp"):
from core import git
d = git.cache_dir("server-up") / "apps" / naam
(d / "files").mkdir(parents=True, exist_ok=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": naam},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": naam, "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8123},
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
"default": "admin"},
{"name": "admin_password", "type": "str", "title": "Wachtwoord",
"default": "", "required": True},
]}]}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n"
" ports:\n - \"<< port >>:80\"\n"
" environment:\n"
" - ADMIN_USER=<< admin_user >>\n"
" - ADMIN_PASSWORD=<< admin_password >>\n", encoding="utf-8")
return d
def _wacht(client, jid, tijdslimiet=10):
einde = time.time() + tijdslimiet
while time.time() < einde:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
return d
time.sleep(0.05)
return {}
def _installeer(client, env, csrf, inst="ia", **waarden):
_bron(env)
v = {"service_name": inst, "admin_user": "ramon",
"admin_password": "MijnWachtwoord1"}
v.update(waarden)
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": inst, "values": v},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
return r
def test_inloggegevens_tonen_naam_en_wachtwoord(client, env):
csrf = login(client)
_installeer(client, env, csrf)
d = client.get("/api/stacks/ia/credentials").get_json()
assert d["ok"] is True
soorten = {i["kind"]: i["value"] for i in d["items"]}
assert soorten["username"] == "ramon"
assert soorten["password"] == "MijnWachtwoord1"
assert d["self_signup"] is False
def test_wachtwoord_is_als_geheim_gemarkeerd(client, env):
"""De interface verbergt het achter een oog-knop."""
csrf = login(client)
_installeer(client, env, csrf, inst="ib")
d = client.get("/api/stacks/ib/credentials").get_json()
wachtwoord = next(i for i in d["items"] if i["kind"] == "password")
naam = next(i for i in d["items"] if i["kind"] == "username")
assert wachtwoord["secret"] is True
assert naam["secret"] is False
def test_app_zonder_inlogvelden_zegt_dat(client, env):
"""Vaultwarden en Immich laten je bij het eerste bezoek zelf een account
aanmaken; dan valt hier niets te tonen."""
from core import git
csrf = login(client)
d = git.cache_dir("server-up") / "apps" / "geeninlog"
(d / "files").mkdir(parents=True, exist_ok=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": "geeninlog"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "geeninlog", "required": True}]}]}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "geeninlog", "repo_id": "server-up",
"instance": "gi", "values": {}},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
res = client.get("/api/stacks/gi/credentials").get_json()
assert res["items"] == []
assert res["self_signup"] is True
def test_inloggegevens_alleen_voor_beheerders(client, env):
"""Ze staan gelijk aan toegang tot de app zelf."""
csrf = login(client)
_installeer(client, env, csrf, inst="ic")
client.post("/api/auth/users",
json={"username": "op", "password": "eenlangwachtwoord",
"role": "operator"},
headers={"X-CSRF-Token": csrf})
client.post("/api/auth/login",
json={"username": "op", "password": "eenlangwachtwoord"})
r = client.get("/api/stacks/ic/credentials")
assert r.status_code == 403
def test_tonen_komt_in_het_auditlog(client, env):
csrf = login(client)
_installeer(client, env, csrf, inst="id")
from core import audit
audit.init()
client.get("/api/stacks/id/credentials")
assert any(r["action"] == "credentials_view" for r in audit.query())
def test_onbekende_stack(client, env):
login(client)
assert client.get("/api/stacks/bestaatniet/credentials").status_code == 404
# ── Zelf bewerken vóór installeren ───────────────────────────────────────────
def test_eigen_compose_wordt_gebruikt(client, env):
csrf = login(client)
_bron(env)
eigen = ("services:\n handmatig:\n image: nginx:1.27\n"
" container_name: zelf-verzonnen\n")
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": "ie",
"values": {"admin_password": "MijnWachtwoord1"},
"compose_override": eigen},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
assert "zelf-verzonnen" in (env["lib"] / "ie" / "docker-compose.yml").read_text()
def test_eigen_env_wordt_gebruikt(client, env):
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": "if",
"values": {"admin_password": "MijnWachtwoord1"},
"env_override": "HANDMATIG=ja\n"},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
assert "HANDMATIG=ja" in (env["lib"] / "if" / ".env").read_text()
def test_kapotte_yaml_wordt_geweigerd_voordat_er_iets_gemaakt_is(client, env):
"""Een typefout hoort hier te stranden, niet halverwege een installatie."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": "ig",
"values": {"admin_password": "MijnWachtwoord1"},
"compose_override": "services: [kapot: ja: nee"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "YAML" in r.get_json()["msg"]
assert not (env["lib"] / "ig").exists()
# ── De .env-editor ───────────────────────────────────────────────────────────
def test_env_editor_weigert_een_regel_zonder_isgelijkteken(client, env):
"""Compose negeert zo'n regel stilzwijgend; dan mist er ineens een
wachtwoord zonder dat je het merkt."""
csrf = login(client)
_installeer(client, env, csrf, inst="ih")
r = client.put("/api/stacks/ih/env",
json={"content": "GOED=1\ndit is onzin\n"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "Regel 2" in r.get_json()["msg"]
def test_env_editor_accepteert_commentaar_en_lege_regels(client, env):
csrf = login(client)
_installeer(client, env, csrf, inst="ii")
r = client.put("/api/stacks/ii/env",
json={"content": "# uitleg\n\nGOED=1\n"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert stat.S_IMODE((env["lib"] / "ii" / ".env").stat().st_mode) == 0o600
def test_env_bewerken_komt_in_het_auditlog(client, env):
csrf = login(client)
_installeer(client, env, csrf, inst="ij")
from core import audit
audit.init()
client.put("/api/stacks/ij/env", json={"content": "X=1\n"},
headers={"X-CSRF-Token": csrf})
assert any(r["action"] == "env_edit" for r in audit.query())
# ── Voorvertoning met eigen waarden ──────────────────────────────────────────
def test_voorvertoning_gebruikt_de_ingevulde_waarden(client, env):
"""Anders toont de laatste stap iets anders dan er straks komt te staan."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/preview",
json={"stack": "inlogapp", "repo_id": "server-up",
"values": {"service_name": "eigennaam", "port": 9999,
"admin_password": "MijnWachtwoord1"}},
headers={"X-CSRF-Token": csrf})
d = r.get_json()
assert "eigennaam" in d["compose_preview"]
assert "9999" in d["compose_preview"]
# Het geheim hoort al in de .env-voorvertoning te staan, niet in compose.
assert "MijnWachtwoord1" not in d["compose_preview"]
assert "MijnWachtwoord1" in d["env_preview"]

View file

@ -12,6 +12,17 @@ sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login from conftest import login
def _wacht_op_job(client, jid, tijdslimiet=10):
"""Installeren gebeurt in een achtergrondthread; wachten tot hij klaar is."""
einde = time.time() + tijdslimiet
while time.time() < einde:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
return d
time.sleep(0.05)
return {}
def _stack(env, naam="web", meta=None, compose=None): def _stack(env, naam="web", meta=None, compose=None):
d = env["lib"] / naam d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@ -65,10 +76,37 @@ def test_reconfigure_van_onbekende_stack(client):
def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch): def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
"""Zonder opgeslagen waarden valt het formulier later niet te heropenen.""" """Zonder opgeslagen waarden valt het formulier later niet te heropenen.
import app as app_module
assert '"values": bp_values if is_bp else {}' in \ Was een controle op een letterlijke regel broncode; die brak zodra de regel
(WORTEL / "server-up" / "app.py").read_text(encoding="utf-8") verplaatste terwijl het gedrag klopte.
"""
import json as _json
from core import git
csrf = login(client)
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "keuzeapp", "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "keuzeapp", "repo_id": "server-up",
"instance": "kz", "values": {"port": 9123}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht_op_job(client, r.get_json()["job_id"])
meta = _json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
assert meta["values"]["port"] == 9123
assert meta["source"] == "keuzeapp"
# ── Koppelen ───────────────────────────────────────────────────────────────── # ── Koppelen ─────────────────────────────────────────────────────────────────

34
tests/test_ui_logica.py Normal file
View file

@ -0,0 +1,34 @@
"""De logica van het invulmenu, uitgevoerd zoals de browser het doet.
De stapindeling, de geavanceerd-schakelaar en het blokkeren van 'Volgende'
zitten in JavaScript. Een Python-test kan daar niets over zeggen, dus draaien we
de echte component uit index.html in Node. Zonder Node slaat dit zichzelf over.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
SCRIPT = WORTEL / "tests" / "js" / "menu.test.js"
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_invulmenu_logica():
r = subprocess.run(["node", str(SCRIPT)], capture_output=True, text=True,
timeout=60, cwd=str(WORTEL))
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_javascript_van_de_pagina_parseert():
"""Een typefout in de component maakt de hele interface blanco; dat wil je
niet pas in de browser ontdekken."""
import re
html = (WORTEL / "server-up" / "templates" / "index.html").read_text(encoding="utf-8")
blokken = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
grootste = max(blokken, key=len)
r = subprocess.run(["node", "--check", "-"], input=grootste,
capture_output=True, text=True, timeout=30)
assert r.returncode == 0, r.stderr