diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5e5d3..32ed782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 Aanleiding: Homepage weigerde na installatie met **"Host validation failed"**. diff --git a/VERSION b/VERSION index 4d714e7..296fb7a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.60-beta +0.7.70-beta diff --git a/apps/changedetection-io/template.json b/apps/changedetection-io/template.json index b39905c..0f625a3 100644 --- a/apps/changedetection-io/template.json +++ b/apps/changedetection-io/template.json @@ -59,7 +59,9 @@ "description": "Optioneel wachtwoord om de webinterface te beveiligen. Laat leeg voor geen beveiliging.", "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] } diff --git a/apps/influxdb/template.json b/apps/influxdb/template.json index 90ed6f0..8e3d5e2 100644 --- a/apps/influxdb/template.json +++ b/apps/influxdb/template.json @@ -84,7 +84,9 @@ "description": "Minimaal 8 tekens", "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] } diff --git a/apps/kimai/template.json b/apps/kimai/template.json index 70bdfec..d3074f0 100644 --- a/apps/kimai/template.json +++ b/apps/kimai/template.json @@ -82,7 +82,9 @@ "description": "Minimaal 8 tekens", "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] } diff --git a/apps/linkding/template.json b/apps/linkding/template.json index fc804c0..28b8155 100644 --- a/apps/linkding/template.json +++ b/apps/linkding/template.json @@ -64,7 +64,9 @@ "required": true, "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] } diff --git a/apps/miniflux/template.json b/apps/miniflux/template.json index e3be5ed..27f8159 100644 --- a/apps/miniflux/template.json +++ b/apps/miniflux/template.json @@ -58,7 +58,9 @@ "required": true, "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] }, diff --git a/apps/photoprism/template.json b/apps/photoprism/template.json index 76d6305..ac745eb 100644 --- a/apps/photoprism/template.json +++ b/apps/photoprism/template.json @@ -67,7 +67,9 @@ "description": "Minimaal 8 tekens", "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true }, { "name": "db_password", diff --git a/apps/pihole/template.json b/apps/pihole/template.json index 84740af..0fb48d8 100644 --- a/apps/pihole/template.json +++ b/apps/pihole/template.json @@ -75,7 +75,9 @@ "description": "Wachtwoord voor het Pi-hole webpaneel", "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] } diff --git a/apps/technitium-dns/template.json b/apps/technitium-dns/template.json index 68b501e..9f24bc0 100644 --- a/apps/technitium-dns/template.json +++ b/apps/technitium-dns/template.json @@ -61,7 +61,9 @@ "description": "Wachtwoord voor de webinterface", "config": { "placeholder": "sterk-wachtwoord" - } + }, + "generate": true, + "secret": true } ] } diff --git a/docs/apps-maken.md b/docs/apps-maken.md index 9acd889..0b0381c 100644 --- a/docs/apps-maken.md +++ b/docs/apps-maken.md @@ -185,6 +185,42 @@ keuze; die moet gewoon willekeurig zijn. Zet daarvoor `generate`: > installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert > 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 `"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op diff --git a/docs/beveiliging.md b/docs/beveiliging.md index cd3e449..a125f2b 100644 --- a/docs/beveiliging.md +++ b/docs/beveiliging.md @@ -106,13 +106,49 @@ je vertrouwt. Twee knoppen om aan te draaien: {"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 - **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints toelaat werkt niet, omdat compose vrijwel alles nodig heeft. -- **Rechten per gebruiker.** Elk account heeft dezelfde volledige toegang; er - zijn geen rollen. - **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een proxy met rate limiting voor. diff --git a/server-up/app.py b/server-up/app.py index eb6d5ac..a7e9308 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -741,10 +741,73 @@ def api_stack_env_put(name): lib, d = _stack_dir(name) if not d: 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) +@app.route("/api/stacks//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//compose") def api_stack_compose(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): meta = _stack_meta_lezen(d) meta.update(updates) - (d / ".serverup.json").write_text( - json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + mf = d / ".serverup.json" + 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//config") @@ -808,12 +912,16 @@ def api_stack_config(name): msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte " "keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. " "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, fields=_annotate_fields(boilerplates.fields(bron)), values=waarden, network=meta.get("network", ""), ip=meta.get("ip", ""), 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//reconfigure", methods=["POST"]) @@ -867,6 +975,22 @@ def api_stack_reconfigure(name): jobs.log(qq, "ok", "Opnieuw gerenderd") 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: net = _network_by_name(net_name) if net and net_ip: @@ -888,8 +1012,11 @@ def api_stack_reconfigure(name): backups.restore(name, backup_naam, log_fn=lf) jobs.finish(jid, "error"); jobs.done(qq); return + zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden) _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)}) docker.compose_down(d, log_fn=lf, name=name) @@ -1204,15 +1331,29 @@ def api_store_preview(): if boilerplates.is_boilerplate(src): meta = boilerplates.metadata(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: - ctx = boilerplates.build_context(src, {}) + ctx = boilerplates.build_context(src, waarden) files_dir = src / "files" preview_files = {} 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: 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: preview_files = {"_error": f"render: {e}"} return jsonify( @@ -1227,7 +1368,7 @@ def api_store_preview(): env_vars=[], images=[], ports=[], compose_preview=preview_files.get("compose.yaml", "") or preview_files.get("docker-compose.yml", ""), - env_preview="", + env_preview=env_preview, ) # ── 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"} image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"} 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_ip = (d.get("ip") or "").strip() # 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), 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() if lib == Path("/app").resolve(): return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.") @@ -1383,6 +1535,12 @@ def api_store_install(): def _run(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: jobs.log(qq, "section", f"Installeren: {inst}") try: @@ -1396,10 +1554,17 @@ def api_store_install(): values = dict(bp_values or {}) # Defaults voor service_name als de gebruiker niets opgaf 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)") # Wachtwoorden en sleutels niet uitschrijven: dit log staat in # 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()): jobs.log(qq, "dim", f" {k}=" + ("••••••" if k in geheim and v else str(v))) try: @@ -1410,6 +1575,33 @@ def api_store_install(): except boilerplates.BoilerplateError as e: jobs.log(qq, "error", str(e)) 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: # ── Legacy: copy verbatim ───────────────────────────────────── shutil.copytree(str(src), str(dest), dirs_exist_ok=True) @@ -1520,7 +1712,12 @@ def api_store_install(): logo = meta.get("logo_url", "") if not logo and not is_bp: 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, "repo_id": rid, "name": meta.get("name", stack), @@ -1536,9 +1733,11 @@ def api_store_install(): # Zonder de gebruikte waarden valt het installatieformulier # later niet opnieuw te openen om iets te wijzigen. "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, - }, ensure_ascii=False, indent=2), encoding="utf-8") + }) except Exception: pass diff --git a/server-up/core/boilerplates.py b/server-up/core/boilerplates.py index 9deb724..b216f20 100644 --- a/server-up/core/boilerplates.py +++ b/server-up/core/boilerplates.py @@ -99,6 +99,10 @@ def fields(d: Path) -> list[dict]: "required": False, "options": [], "needs": [], "placeholder": "", "description": grp.get("description", ""), "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")) 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), # Waarden die de gebruiker toch niet zelf kan bedenken (JWT- # 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 "", + # 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 +# ── 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 # nieuw template het per ongeluk als gewoon tekstveld toont. _GEHEIM_WOORDEN = ("password", "passwd", "secret", "token", "apikey", diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py index 8c3c56a..cf0a1bd 100644 --- a/server-up/core/compose_transform.py +++ b/server-up/core/compose_transform.py @@ -127,6 +127,121 @@ def add_shared_network(compose_text: str, network: str) -> str: 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]: """Alle host-poorten die dit compose-bestand publiceert (voor de UI).""" try: diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 5493074..8bcca72 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -302,8 +302,17 @@ def read_env(d: Path) -> 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.write_text(content) + try: + f.chmod(0o600) + except OSError: + pass # bv. op een filesystem dat geen rechten kent def read_compose(d: Path) -> str: diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 29e1daa..05812f9 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -355,6 +355,7 @@ tailwind.config = { + @@ -1358,12 +1359,6 @@ tailwind.config = { Boilerplate - - - - -
- - - -

-
Configuratie laden…
- +