v0.7.60-beta - app-instellingen nagelopen op fouten bij het installeren
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m27s

Aanleiding: Homepage weigerde met "Host validation failed" omdat de
standaardwaarde van allowed_hosts poort 3001 noemde terwijl het poortveld op
3002 stond. Alle 96 sjablonen zijn daarop nagelopen.

Systemisch:
- De dobbelsteenknop, waar zeventien apps in hun uitleg naar verwijzen,
  verscheen nooit: hij hing af van een veld 'secret' dat fields() niet
  meestuurde. Wachtwoorden stonden daardoor leesbaar in het formulier.
- 27 velden die je toch nooit zelf typt (databasewachtwoorden, JWT- en
  versleutelingssleutels) worden nu voorgevuld met een willekeurige waarde.
  Inlogwachtwoorden krijgen alleen een knop, want die moet je noteren.
- De knop hangt nu aan 'generate' in plaats van aan 'secret': voor een
  WireGuard-privésleutel of een token uit een andere app is een verzonnen
  waarde juist fout.
- required werd nergens gecontroleerd. Je kon installeren met een leeg
  databasewachtwoord of een lege sleutel. Nu geweigerd bij installeren en bij
  herconfigureren, met vermelding van de lege velden. Velden achter een
  uitgeschakelde groepsschakelaar tellen niet mee.
- Wachtwoorden werden uitgeschreven in het installatielog; nu gemaskeerd.

Losse fouten:
- homepage: allowed_hosts stond op localhost:3001, nu * met uitleg.
- baserow, hedgedoc, ntfy: URL in de standaardwaarde wees naar een poort waar
  niets luistert.
- Vast wachtwoord in acht sjablonen weggehaald: ghost, immich, miniflux,
  paperless-ngx, unifi-network en vikunja kregen allemaal dezelfde
  database-wachtwoorden; grafana en gotify stonden op 'admin'.
- data_dir bij keycloak, metube, miniflux en teslamate stond in het formulier
  maar kwam nergens terecht (die apps gebruiken een Docker-volume).
- puid/pgid bij freshrss: het image kent ze niet.
- prometheus gaf de gevraagde tijdzone niet door.

Nieuw tests/test_app_templates.py: rendert elke app met zijn standaardwaarden
en controleert ongedefinieerde variabelen, velden zonder werking,
niet-gedeclareerde volumes, depends_on, dubbele containernamen,
poortconflicten, poortnummers in standaardwaarden en vaste wachtwoorden.
2236 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 20:08:07 +02:00
parent 87fb9d19d1
commit 952e7ddb7a
42 changed files with 714 additions and 103 deletions

View file

@ -1,3 +1,59 @@
# v0.7.60-beta — App-instellingen die niet meer stilzwijgend misgaan
Aanleiding: Homepage weigerde na installatie met **"Host validation failed"**.
De standaardwaarde van `allowed_hosts` noemde poort 3001 terwijl het poortveld
op 3002 stond — en `localhost` werkt sowieso niet als je je server op zijn
IP-adres benadert. Dat bleek geen los geval, dus alle 96 sjablonen zijn
nagelopen.
**De dobbelsteenknop bestond niet.** Zeventien apps zeggen in hun uitleg
"gebruik de dobbelsteenknop voor een willekeurige waarde". Die knop hing af van
een veld `secret` dat de backend nooit meestuurde, dus hij verscheen nooit — en
wachtwoorden stonden als gewone leesbare tekst in het formulier. Nu worden
geheimen herkend, en 27 velden die je toch nooit zelf typt (databasewachtwoorden,
JWT- en versleutelingssleutels) worden meteen ingevuld met een willekeurige
waarde. Inlogwachtwoorden krijgen alleen de knop: die moet je zelf noteren.
De knop hangt nu aan "hier past een willekeurige waarde" en niet aan "dit is
geheim" — voor een WireGuard-privésleutel of een token uit een andere app is
iets verzonnens juist fout.
**Verplichte velden werden niet gecontroleerd.** Het formulier zette een
sterretje achter een verplicht veld en hield verder niets tegen: je kon een app
installeren met een leeg databasewachtwoord of een lege sleutel. De container
start dan niet, of draait met een leeg geheim. Dat wordt nu geweigerd, bij
installeren én bij het achteraf wijzigen van instellingen, met de melding welke
velden nog leeg zijn. Velden achter een uitgeschakelde groepsschakelaar tellen
niet mee.
**Acht apps hadden een vast wachtwoord in het sjabloon**: `immich_db_pass`,
`ghost_db_pass` en soortgelijke voor iedereen die het installeert, en `admin` als
beheerderswachtwoord van Grafana en Gotify. De databasewachtwoorden worden nu
gegenereerd; bij Grafana en Gotify moet je zelf een wachtwoord kiezen.
**Wachtwoorden stonden in het installatielog**, dat in de interface zichtbaar is
en vaak in een bugmelding geplakt wordt. Die worden nu gemaskeerd.
Verder rechtgezet:
- `baserow`, `hedgedoc` en `ntfy` hadden dezelfde poortfout als Homepage: de
URL in de standaardwaarde wees naar een poort waar niets luistert, waardoor
links en terugkeer-na-inloggen niet werkten.
- Zes velden stonden in het formulier maar kwamen nergens terecht: `data_dir`
bij Keycloak, MeTube, Miniflux en TeslaMate (die gebruiken een Docker-volume,
dus je gegevens stonden niet waar je dacht), en `puid`/`pgid` bij FreshRSS,
die het image helemaal niet kent.
- De tijdzone van Prometheus werd wel gevraagd maar niet doorgegeven.
Nieuw is `tests/test_app_templates.py`: elke app wordt met zijn
standaardwaarden gerenderd en gecontroleerd op ongedefinieerde variabelen,
velden zonder werking, niet-gedeclareerde volumes, `depends_on` naar een
onbekende service, dubbele containernamen, poortconflicten binnen een stack,
poortnummers in standaardwaarden die niet kloppen met het poortveld, en vaste
wachtwoorden. 2236 tests groen.
---
# v0.7.50-beta — Je hoort het als er iets misgaat
Het onbewaakte deel was de zwakke plek. Geplande backups, updatechecks en de

View file

@ -1 +1 @@
0.7.50-beta
0.7.60-beta

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "session_secret",
@ -73,7 +75,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "storage_key",
@ -85,7 +89,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
}
]
}

View file

@ -77,7 +77,9 @@
"description": "Willekeurige waarde voor sessieversleuteling; gebruik de dobbelsteenknop",
"config": {
"placeholder": "willekeurige-lange-string"
}
},
"generate": "auto",
"secret": true
},
{
"name": "allowed_hosts",

View file

@ -56,9 +56,10 @@
"name": "public_url",
"type": "str",
"title": "Publieke URL",
"default": "http://localhost:8098",
"default": "http://localhost:8099",
"required": true,
"description": "Zonder dit kloppen links en uploads niet"
"description": "Zonder dit kloppen links en uploads niet",
"help": "Het adres waarop je Baserow opent. Baserow bouwt hier zijn links mee op, dus een verkeerde poort levert knoppen op die nergens heen gaan. Benader je de server op zijn IP-adres, vul dan dat adres in: http://10.0.20.5:8099"
}
]
}

View file

@ -78,7 +78,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "app_url",

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "app_key",
@ -73,7 +75,9 @@
"config": {
"placeholder": "32-tekens-lange-sleutel"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
}
]
}

View file

@ -50,21 +50,6 @@
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
},
{
"name": "puid",
"type": "int",
"title": "Gebruikers-ID (PUID)",
"default": 1000,
"required": true,
"description": "Bepaalt de eigenaar van de bestanden; `id -u` op je host"
},
{
"name": "pgid",
"type": "int",
"title": "Groeps-ID (PGID)",
"default": 1000,
"required": true
}
]
}

View file

@ -70,11 +70,14 @@
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "ghost_db_pass",
"default": "",
"required": true,
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": "auto",
"help": "Wordt automatisch ingevuld met een willekeurige waarde. Alleen de containers onderling gebruiken hem, dus je hoeft hem niet te onthouden."
}
]
}

View file

@ -54,12 +54,15 @@
"name": "default_password",
"type": "str",
"title": "Standaard admin wachtwoord",
"default": "admin",
"default": "",
"required": true,
"description": "Wijzig dit na de eerste aanmelding",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": true,
"help": "Hiermee log je zelf in, dus noteer hem voordat je verder gaat. Stond eerder op 'admin' — dat is de standaard van de leverancier en betekent dat iedereen die de poort vindt erin kan."
}
]
}

View file

@ -61,12 +61,15 @@
"name": "admin_password",
"type": "str",
"title": "Admin wachtwoord",
"default": "admin",
"default": "",
"required": true,
"description": "Wijzig dit na de eerste aanmelding",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": true,
"help": "Hiermee log je zelf in, dus noteer hem voordat je verder gaat. Stond eerder op 'admin' — dat is de standaard van de leverancier en betekent dat iedereen die de poort vindt erin kan."
}
]
}

View file

@ -62,14 +62,17 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "app_url",
"type": "str",
"title": "Basis-URL",
"default": "http://localhost:3010",
"required": true
"default": "http://localhost:3011",
"required": true,
"help": "Het adres waarop je HedgeDoc opent. HedgeDoc gebruikt dit voor deel-links en voor de terugkeer na het inloggen, dus een verkeerde poort breekt allebei. Benader je de server op zijn IP-adres, vul dan dat adres in: http://10.0.20.5:3011"
}
]
}

View file

@ -55,9 +55,10 @@
"name": "allowed_hosts",
"type": "str",
"title": "Toegestane hostnamen",
"default": "localhost:3001",
"default": "*",
"required": true,
"description": "Komma-gescheiden; anders weigert Homepage het verzoek"
"description": "Komma-gescheiden host:poort, of * voor alles",
"help": "Homepage weigert sinds v0.9 elk verzoek waarvan de Host-header er niet bij staat; je krijgt dan 'Host validation failed'.\n\nMet * accepteert Homepage elke hostnaam. Dat is hier de standaard omdat je je server meestal op zijn IP-adres benadert en dat adres vooraf niet bekend is.\n\nWil je het strakker: vul dan zelf in waarmee je Homepage opent, bijvoorbeeld 10.0.20.5:3002,homepage.thuis.nl. De poort hoort erbij en moet gelijk zijn aan de webpoort hierboven."
}
]
}

View file

@ -63,13 +63,16 @@
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "immich_db_pass",
"default": "",
"required": true,
"description": "Wachtwoord voor de interne PostgreSQL database",
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"secret": true,
"generate": "auto",
"help": "Wordt automatisch ingevuld met een willekeurige waarde. Alleen de containers onderling gebruiken hem, dus je hoeft hem niet te onthouden."
}
]
}

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "openai_key",

View file

@ -37,13 +37,6 @@
"default": 8103,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
},
{
"name": "timezone",
"type": "str",
@ -67,7 +60,10 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"generate": true,
"secret": true,
"help": "Hiermee log je straks zelf in, dus noteer hem voordat je verder gaat. De knop maakt een sterk wachtwoord; je mag ook je eigen wachtwoord gebruiken."
},
{
"name": "db_password",
@ -79,7 +75,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
}
]
}

View file

@ -62,7 +62,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "admin_email",

View file

@ -81,7 +81,10 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"generate": true,
"secret": true,
"help": "Hiermee log je straks zelf in, dus noteer hem voordat je verder gaat. De knop maakt een sterk wachtwoord; je mag ook je eigen wachtwoord gebruiken."
}
]
}

View file

@ -38,13 +38,6 @@
"default": 8105,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
},
{
"name": "timezone",
"type": "str",

View file

@ -37,13 +37,6 @@
"title": "Web poort",
"default": 8080,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
}
]
},
@ -76,11 +69,14 @@
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "miniflux_db_pass",
"default": "",
"required": true,
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": "auto",
"help": "Wordt automatisch ingevuld met een willekeurige waarde. Alleen de containers onderling gebruiken hem, dus je hoeft hem niet te onthouden."
}
]
}

View file

@ -55,8 +55,9 @@
"name": "base_url",
"type": "str",
"title": "Basis-URL",
"default": "http://localhost:8101",
"required": true
"default": "http://localhost:8109",
"required": true,
"help": "Het adres waarop ntfy bereikbaar is. Dit staat in de berichten en in de instructies die ntfy toont, en de telefoon-app gebruikt het om te abonneren. Wijs het naar je server: http://10.0.20.5:8109"
},
{
"name": "auth_default",

View file

@ -74,7 +74,9 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"generate": "auto",
"secret": true
}
]
}

View file

@ -67,17 +67,22 @@
"description": "Willekeurige lange string voor sessiebeveiliging",
"config": {
"placeholder": "minimaal-50-willekeurige-tekens"
}
},
"generate": "auto",
"secret": true
},
{
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "paperless_db_pass",
"default": "",
"required": true,
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": "auto",
"help": "Wordt automatisch ingevuld met een willekeurige waarde. Alleen de containers onderling gebruiken hem, dus je hoeft hem niet te onthouden."
}
]
},

View file

@ -79,7 +79,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
}
]
}

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "secret_key",
@ -73,7 +75,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "base_url",
@ -98,7 +102,10 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"generate": true,
"secret": true,
"help": "Hiermee log je straks zelf in, dus noteer hem voordat je verder gaat. De knop maakt een sterk wachtwoord; je mag ook je eigen wachtwoord gebruiken."
}
]
}

View file

@ -4,6 +4,8 @@ services:
container_name: << service_name >>
ports:
- "<< port >>:9090"
environment:
- TZ=<< timezone >>
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=<< retention >>

View file

@ -68,7 +68,9 @@
"description": "Willekeurige waarde; gebruik de dobbelsteenknop",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"generate": "auto",
"secret": true
}
]
}

View file

@ -69,7 +69,9 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"generate": "auto",
"secret": true
}
]
}

View file

@ -88,7 +88,9 @@
"description": "Willekeurige 32-tekens encryptiesleutel, bijv. base64:xxxx...",
"config": {
"placeholder": "base64:willekeurige-string-van-32-tekens"
}
},
"generate": "auto",
"secret": true
}
]
}

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "secret_key",
@ -73,7 +75,9 @@
"config": {
"placeholder": "willekeurige-lange-string"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
}
]
}

View file

@ -38,13 +38,6 @@
"default": 4000,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
},
{
"name": "timezone",
"type": "str",
@ -62,7 +55,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "encryption_key",
@ -74,7 +69,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "grafana_port",

View file

@ -109,12 +109,15 @@
"name": "mongo_password",
"type": "str",
"title": "MongoDB wachtwoord",
"default": "unifi_mongo_pass",
"default": "",
"required": true,
"description": "Wachtwoord voor de interne MongoDB database",
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": "auto",
"help": "Wordt automatisch ingevuld met een willekeurige waarde. Alleen de containers onderling gebruiken hem, dus je hoeft hem niet te onthouden."
}
]
}

View file

@ -65,7 +65,9 @@
"description": "Willekeurige geheime string voor het /admin paneel. Laat leeg om admin uit te schakelen.",
"config": {
"placeholder": "willekeurige-geheime-string"
}
},
"generate": "auto",
"secret": true
}
]
}

View file

@ -66,17 +66,22 @@
"description": "Willekeurige geheime string voor JWT-tokenbeveiliging",
"config": {
"placeholder": "willekeurige-lange-string"
}
},
"generate": "auto",
"secret": true
},
{
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "vikunja_db_pass",
"default": "",
"required": true,
"config": {
"placeholder": "sterk-wachtwoord"
}
},
"secret": true,
"generate": "auto",
"help": "Wordt automatisch ingevuld met een willekeurige waarde. Alleen de containers onderling gebruiken hem, dus je hoeft hem niet te onthouden."
}
]
}

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
},
{
"name": "app_url",

View file

@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
"advanced": true
"advanced": true,
"generate": "auto",
"secret": true
}
]
}

View file

@ -160,8 +160,36 @@ Deze namen worden speciaal behandeld:
alles in de stack. Wordt automatisch gevuld met de instantienaam.
- **Velden met `port` in de naam en type `int`** — krijgen bij het installeren
automatisch een vrij poortnummer voorgesteld als de standaard al bezet is.
- **Velden met `token`, `password`, `secret` of `key` in de naam** — krijgen een
knop die een willekeurige waarde genereert.
- **Velden met `token`, `password`, `secret`, `wachtwoord`, `jwt` of `key` in de
naam** — worden als geheim behandeld: de waarde wordt gemaskeerd in het
installatielog en het veld krijgt een eigen weergave. Je kunt het ook
afdwingen met `"secret": true`.
### Waarden laten genereren
Voor een databasewachtwoord of een JWT-sleutel heeft de gebruiker geen zinnige
keuze; die moet gewoon willekeurig zijn. Zet daarvoor `generate`:
```json
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
"default": "", "required": true, "generate": "auto"}
```
| Waarde | Wat er gebeurt |
|---|---|
| `"auto"` | Het veld wordt bij het openen van het formulier gevuld met 32 willekeurige tekens. Voor waarden die de gebruiker nooit hoeft te kennen. |
| `true` | Alleen een knop naast het veld. Voor wachtwoorden waarmee de gebruiker zélf inlogt: die moet hij noteren, dus vul ze niet ongevraagd in. |
| weglaten | Geen knop. Voor waarden met een eigen formaat of herkomst — een bcrypt-hash, een token uit een andere app, een sleutel die bij iets anders moet passen. Iets verzonnens is daar juist fout. |
> **Zet nooit een wachtwoord als `default`.** Dan krijgt iedereen die de app
> installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert
> sjablonen met een vaste waarde op een geheim veld.
### Verplichte velden
`"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op
in plaats van een container die niet start of met een leeg geheim draait. Zit
het veld achter een groepsschakelaar die uitstaat, dan telt het niet mee.
### Geavanceerde velden

View file

@ -841,6 +841,13 @@ def api_stack_reconfigure(name):
verbinden = bool(meta.get("connected"))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
ontbreekt = boilerplates.ontbrekende_verplichte(bron, nieuwe_waarden)
if ontbreekt:
return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt),
missing=ontbreekt), 400
jid, q = jobs.create(f"reconfigure:{name}")
def _run(jid):
@ -1348,6 +1355,17 @@ def api_store_install():
if not is_bp and not docker.has_compose(src):
return jsonify(ok=False, msg=f"'{stack}' heeft geen compose bestand")
# Verplichte velden vóór het aanmaken van de job: een leeg wachtwoord of
# een lege sleutel levert een container op die niet start of die met een
# leeg geheim draait. Het formulier toonde alleen een sterretje.
if is_bp:
waarden = dict(bp_values or {})
waarden.setdefault("service_name", inst)
ontbreekt = boilerplates.ontbrekende_verplichte(src, waarden)
if ontbreekt:
return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt),
missing=ontbreekt), 400
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
if lib == Path("/app").resolve():
return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.")
@ -1379,8 +1397,11 @@ def api_store_install():
# Defaults voor service_name als de gebruiker niets opgaf
values.setdefault("service_name", inst)
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")}
for k, v in sorted(values.items()):
jobs.log(qq, "dim", f" {k}={v}")
jobs.log(qq, "dim", f" {k}=" + ("••••••" if k in geheim and v else str(v)))
try:
written = boilerplates.render_to_dir(src, dest, values)
for rel in written:

View file

@ -126,10 +126,76 @@ def fields(d: Path) -> list[dict]:
# leeg tekstveld. {"app": "mosquitto", "scheme": "mqtt",
# "port": 1883, "path": ""}
"connect": item.get("connect") or None,
# Wachtwoorden en sleutels: het invoerveld verbergt de tekst en
# de waarde wordt gemaskeerd in het installatielog.
"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.
"generate": item.get("generate") or "",
})
return out
# 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",
"jwt", "wachtwoord", "sleutel", "adminpass")
# Losse woorddelen: 'key' als heel woord in de naam (app_key, agent_key,
# wireguard_private_key), maar niet in iets als 'keyboard_layout'.
_GEHEIM_DELEN = ("key", "keys")
def _lijkt_geheim(item: dict) -> bool:
naam = (item.get("name") or "").lower()
if any(w in naam for w in _GEHEIM_WOORDEN):
return True
return any(deel in _GEHEIM_DELEN for deel in naam.split("_"))
def ontbrekende_verplichte(d: Path, values: dict[str, Any]) -> list[str]:
"""Verplichte velden zonder waarde.
Zonder deze controle installeerde je een app met een leeg wachtwoord of een
lege sleutel: de container start dan met een onbruikbare configuratie, of
erger met een geheim dat leeg is. Het formulier toonde alleen een
sterretje en hield niets tegen.
"""
ontbreekt = []
for veld in fields(d):
if not veld.get("required") or veld.get("is_group_toggle"):
continue
# Velden die achter een uitgeschakelde groepsschakelaar zitten tellen
# niet mee; die komen sowieso niet in het compose-bestand terecht.
toggle = veld.get("group_toggle")
if toggle and not _waarheid(values.get(toggle, True)):
continue
if not _waarheid(veld.get("needs"), values):
continue
waarde = values.get(veld["name"], veld.get("default"))
if waarde is None or (isinstance(waarde, str) and not waarde.strip()):
ontbreekt.append(veld.get("title") or veld["name"])
return ontbreekt
def _waarheid(waarde, values: dict | None = None) -> bool:
"""Evalueer een schakelaarwaarde, of een `needs`-lijst tegen de waarden."""
if values is not None:
# waarde is hier de needs-lijst: elke voorwaarde moet kloppen.
for nodig in (waarde or []):
if isinstance(nodig, str):
if not _waarheid(values.get(nodig)):
return False
elif isinstance(nodig, dict):
naam = nodig.get("name") or nodig.get("field")
if naam and str(values.get(naam, "")) != str(nodig.get("value", "")):
return False
return True
if isinstance(waarde, str):
return waarde.strip().lower() not in ("", "0", "false", "no", "nee", "off")
return bool(waarde)
# ── Templating engine ────────────────────────────────────────────────────────
class BoilerplateError(Exception):

View file

@ -1482,7 +1482,7 @@ tailwind.config = {
<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" :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>
</div>
</template>
@ -1543,7 +1543,7 @@ tailwind.config = {
<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" :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>
</div>
</template>
@ -2541,6 +2541,14 @@ function app() {
const doelen = this.connectTargets(f);
if (doelen.length) init[f.name] = doelen[0].value;
}
// Database-wachtwoorden en interne sleutels meteen invullen. Die hoef
// je nooit zelf te typen, en een leeg veld leverde een stack op die
// niet startte of met een leeg geheim draaide.
for (const f of (d.fields || [])) {
// 'auto': waarden die je nooit hoeft te kennen. Een inlogwachtwoord
// krijgt alleen een knop, want dat moet je zelf noteren.
if (f.generate === 'auto' && !init[f.name]) init[f.name] = this.randomSecret();
}
this.install.values = init;
this.install.preview_files = d.preview_files || {};
} else {

222
tests/test_app_templates.py Normal file
View file

@ -0,0 +1,222 @@
"""Alle app-sjablonen doorlopen op fouten die pas bij het installeren opvallen.
Deze controles zijn ontstaan uit echte fouten: een Homepage die met
"Host validation failed" weigerde omdat de standaardwaarde een andere poort
noemde dan het poortveld, en velden die in het formulier stonden maar nergens
in het compose-bestand terechtkwamen.
"""
import json
import os
import re
import sys
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
os.environ.setdefault("SU_CONFIG", "/tmp/su-templates-test.json")
from core import boilerplates # noqa: E402
APPS = WORTEL / "apps"
VAR_RE = re.compile(r"<<\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|[^>]*?)?>>")
BLOK_RE = re.compile(r"<%-?\s*(?:if|elif)\s+([^%]+?)\s*-?%>")
SLEUTELWOORDEN = {"and", "or", "not", "in", "is", "if", "elif",
"true", "false", "none", "defined"}
def _apps():
return sorted(d for d in APPS.iterdir()
if d.is_dir() and (d / "template.json").is_file())
def _compose_bestanden(d):
f = d / "files"
return sorted(p for p in f.iterdir()
if f.is_dir() and p.suffix in (".yaml", ".yml")
and "compose" in p.name.lower())
def _gerenderd(d):
"""Render elk compose-bestand met de standaardwaarden en geef de YAML."""
velden = boilerplates.fields(d)
waarden = {f["name"]: (f["default"] if f["default"] is not None else "")
for f in velden}
uit = []
for p in _compose_bestanden(d):
tekst = boilerplates.render_text(p.read_text(encoding="utf-8"), waarden)
uit.append((p, yaml.safe_load(tekst)))
return uit
IDS = [d.name for d in _apps()]
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_template_is_geldige_json(app):
json.loads((app / "template.json").read_text(encoding="utf-8"))
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_rendert_naar_geldige_compose(app):
for pad, doc in _gerenderd(app):
assert isinstance(doc, dict), f"{pad.name} levert geen mapping op"
assert doc.get("services"), f"{pad.name} heeft geen services"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_alle_variabelen_zijn_gedefinieerd(app):
"""Een variabele zonder veld rendert leeg; dat levert stilzwijgend een
kapotte instelling op in plaats van een foutmelding."""
namen = {f["name"] for f in boilerplates.fields(app)}
for p in _compose_bestanden(app):
ruw = p.read_text(encoding="utf-8")
gebruikt = set(VAR_RE.findall(ruw))
for blok in BLOK_RE.findall(ruw):
gebruikt |= (set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", blok))
- SLEUTELWOORDEN)
onbekend = sorted(gebruikt - namen)
assert not onbekend, f"{p.name}: niet gedefinieerd: {onbekend}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_velden_die_nergens_gebruikt_worden(app):
"""Een veld dat nergens terechtkomt, liegt tegen de gebruiker: je vult een
datamap in en je gegevens staan ergens anders."""
velden = [f for f in boilerplates.fields(app) if not f.get("is_group_toggle")]
gebruikt = set()
for p in _compose_bestanden(app):
ruw = p.read_text(encoding="utf-8")
gebruikt |= set(VAR_RE.findall(ruw))
for blok in BLOK_RE.findall(ruw):
gebruikt |= set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", blok))
# Ook andere bestanden in files/ mogen variabelen gebruiken.
for p in (app / "files").rglob("*"):
if p.is_file() and p.suffix not in (".yaml", ".yml"):
try:
gebruikt |= set(VAR_RE.findall(p.read_text(encoding="utf-8")))
except (UnicodeDecodeError, OSError):
pass
ongebruikt = sorted(f["name"] for f in velden if f["name"] not in gebruikt)
assert not ongebruikt, f"velden zonder werking: {ongebruikt}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_named_volumes_staan_bovenaan(app):
"""Compose weigert een volume dat niet in het top-level blok staat."""
for pad, doc in _gerenderd(app):
top = set(doc.get("volumes") or {})
for snaam, svc in (doc.get("services") or {}).items():
for v in (svc or {}).get("volumes") or []:
if not isinstance(v, str):
continue
bron = v.split(":")[0]
if bron and not bron.startswith(("/", ".", "~", "$")):
assert bron in top, f"{pad.name}: {snaam} gebruikt '{bron}'"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_depends_on_verwijst_naar_bestaande_services(app):
for pad, doc in _gerenderd(app):
services = doc.get("services") or {}
for snaam, svc in services.items():
dep = (svc or {}).get("depends_on")
for d in (list(dep) if isinstance(dep, (list, dict)) else []):
assert d in services, f"{pad.name}: {snaam} → onbekend '{d}'"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_dubbele_containernamen(app):
for pad, doc in _gerenderd(app):
namen = [svc.get("container_name") for svc in (doc.get("services") or {}).values()
if isinstance(svc, dict) and svc.get("container_name")]
assert len(namen) == len(set(namen)), f"{pad.name}: dubbel in {namen}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_poortconflict_binnen_een_stack(app):
"""Twee services die dezelfde hostpoort claimen levert een stack op die
half start. TCP en UDP op hetzelfde nummer mag wel."""
for pad, doc in _gerenderd(app):
bezet = {}
for snaam, svc in (doc.get("services") or {}).items():
for pr in (svc or {}).get("ports") or []:
s = str(pr)
proto = "udp" if s.endswith("/udp") else "tcp"
deel = s.replace("/udp", "").replace("/tcp", "").split(":")
if len(deel) >= 2 and deel[-2].strip('"').isdigit():
bezet.setdefault((deel[-2].strip('"'), proto), []).append(snaam)
dubbel = {k: v for k, v in bezet.items() if len(v) > 1}
assert not dubbel, f"{pad.name}: {dubbel}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_poort_in_een_standaardwaarde_klopt_met_het_poortveld(app):
"""Homepage weigerde met 'Host validation failed' omdat allowed_hosts
poort 3001 noemde terwijl het poortveld op 3002 stond. Dezelfde fout zat in
baserow, hedgedoc en ntfy."""
velden = boilerplates.fields(app)
poorten = {f["default"] for f in velden
if f["type"] == "int" and "port" in f["name"]
and isinstance(f["default"], int)}
if not poorten:
return
for f in velden:
d = f.get("default")
if not isinstance(d, str):
continue
for m in re.finditer(r"[a-zA-Z0-9_.-]+:(\d{2,5})\b", d):
genoemd = int(m.group(1))
# Alleen poorten die duidelijk bij deze app horen; een willekeurig
# ander nummer (een SMTP-poort bijvoorbeeld) valt hier buiten.
if any(abs(genoemd - p) < 1000 for p in poorten):
assert genoemd in poorten, (
f"{f['name']}={d!r} noemt poort {genoemd}, "
f"maar de poortvelden staan op {sorted(poorten)}")
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_wachtwoordvelden_zijn_als_geheim_gemarkeerd(app):
"""Anders staat het wachtwoord leesbaar in beeld en ontbreekt de knop om er
een te laten maken."""
for f in boilerplates.fields(app):
naam = f["name"].lower()
if any(w in naam for w in ("password", "secret", "token", "_key")):
assert f["secret"] is True, f"{f['name']} is niet als geheim gemarkeerd"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_vast_wachtwoord_in_het_sjabloon(app):
"""Acht apps hadden er een: 'immich_db_pass' voor iedereen die het
installeert, en 'admin' als beheerderswachtwoord van Grafana en Gotify.
Een standaardwaarde die overal hetzelfde is, is geen wachtwoord."""
for f in boilerplates.fields(app):
if not f.get("secret"):
continue
waarde = str(f.get("default") or "").strip()
assert not waarde, (
f"{f['name']} heeft een vaste waarde ({waarde!r}); maak het veld "
f"leeg en zet 'generate' aan, of laat de gebruiker het invullen")
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_verplichte_geheimen_zijn_invulbaar_of_worden_gegenereerd(app):
"""Een verplicht geheim zonder standaardwaarde moet óf automatisch gevuld
worden, óf iets zijn dat de gebruiker zelf aanlevert. Wat overblijft is een
veld waar niemand een geldige waarde voor kan verzinnen."""
# Wachtwoorden waarmee je zelf inlogt en tokens die je elders ophaalt vult
# de gebruiker bewust zelf in.
eigen_invoer = ("admin_password", "web_password", "ui_password",
"superuser_password", "password_hash", "hass_token",
"forgejo_token", "adminpass")
for f in boilerplates.fields(app):
if not (f.get("required") and f.get("secret")):
continue
if str(f.get("default") or "").strip():
continue
if f.get("generate") or f["name"] in eigen_invoer:
continue
pytest.fail(f"{f['name']}: verplicht geheim zonder waarde, zonder "
f"generator en niet als eigen invoer aangemerkt")

View file

@ -0,0 +1,160 @@
"""Verplichte velden mogen niet leeg blijven, en geheimen horen gemaskeerd.
Het formulier zette een sterretje achter een verplicht veld en hield verder
niets tegen: je kon een app installeren met een leeg databasewachtwoord of een
lege JWT-sleutel. De container start dan niet, of draait met een leeg geheim.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
def _maak_bron(env, naam="testapp", items=None, groepen=None):
"""Zet een app-sjabloon neer waar _find_stack_src het verwacht."""
from core import git
wortel = git.cache_dir("server-up") / "apps" / naam
(wortel / "files").mkdir(parents=True, exist_ok=True)
variabelen = groepen or [{"title": "Algemeen", "items": items or []}]
(wortel / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": naam, "description": "test"},
"variables": variabelen,
}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n"
" << service_name >>:\n"
" image: nginx\n"
" environment:\n"
" - DB_PASSWORD=<< db_password >>\n", encoding="utf-8")
return wortel
VELDEN = [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "testapp", "required": True},
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
"default": "", "required": True},
]
def test_leeg_verplicht_veld_wordt_geweigerd(client, env):
csrf = login(client)
_maak_bron(env, items=VELDEN)
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t1", "values": {"db_password": ""}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400, r.get_json()
d = r.get_json()
assert "Databasewachtwoord" in d["msg"]
assert "Databasewachtwoord" in d["missing"]
def test_ontbrekend_veld_wordt_geweigerd(client, env):
"""Niet meegestuurd is net zo fout als leeg meegestuurd."""
csrf = login(client)
_maak_bron(env, items=VELDEN)
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t2", "values": {}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "Databasewachtwoord" in r.get_json()["missing"]
def test_alleen_spaties_telt_als_leeg(client, env):
csrf = login(client)
_maak_bron(env, items=VELDEN)
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t3", "values": {"db_password": " "}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_ingevuld_veld_gaat_door(client, env):
csrf = login(client)
_maak_bron(env, items=VELDEN)
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t4", "values": {"db_password": "geheim123"}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert r.get_json().get("job_id")
def test_veld_met_een_default_hoeft_niet_meegestuurd(client, env):
"""service_name heeft een standaardwaarde; die telt als ingevuld."""
csrf = login(client)
_maak_bron(env, items=VELDEN)
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t5", "values": {"db_password": "x"}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
def test_veld_in_uitgeschakelde_groep_telt_niet_mee(client, env):
"""Staat de groepsschakelaar uit, dan komt het veld niet in compose terecht
en mag het dus leeg blijven."""
csrf = login(client)
_maak_bron(env, groepen=[
{"title": "Algemeen", "items": [VELDEN[0]]},
{"title": "Database", "toggle": "gebruik_db", "toggle_default": False,
"items": [{"name": "db_password", "type": "str", "title": "Wachtwoord",
"default": "", "required": True}]},
])
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t6",
"values": {"gebruik_db": False, "db_password": ""}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
def test_veld_in_ingeschakelde_groep_telt_wel_mee(client, env):
csrf = login(client)
_maak_bron(env, groepen=[
{"title": "Algemeen", "items": [VELDEN[0]]},
{"title": "Database", "toggle": "gebruik_db", "toggle_default": True,
"items": [{"name": "db_password", "type": "str", "title": "Wachtwoord",
"default": "", "required": True}]},
])
r = client.post("/api/store/install",
json={"stack": "testapp", "repo_id": "server-up",
"instance": "t7",
"values": {"gebruik_db": True, "db_password": ""}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "Wachtwoord" in r.get_json()["missing"]
# ── Geheimen herkennen ───────────────────────────────────────────────────────
@pytest.mark.parametrize("naam", [
"db_password", "admin_password", "jwt_secret", "secret_key", "api_key",
"app_key", "encryption_key", "admin_token", "hass_token",
])
def test_geheime_velden_worden_herkend(env, naam):
"""Zonder deze markering toonde de interface het wachtwoord als gewone
tekst én verscheen de dobbelsteenknop niet, terwijl de beschrijving van
zeventien apps ernaar verwijst."""
from core import boilerplates
wortel = _maak_bron(env, items=[
{"name": naam, "type": "str", "title": "X", "default": "", "required": True}])
veld = boilerplates.fields(wortel)[0]
assert veld["secret"] is True
@pytest.mark.parametrize("naam", ["service_name", "port", "data_dir", "timezone"])
def test_gewone_velden_zijn_geen_geheim(env, naam):
from core import boilerplates
wortel = _maak_bron(env, items=[
{"name": naam, "type": "str", "title": "X", "default": "x"}])
assert boilerplates.fields(wortel)[0]["secret"] is False