diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67d6864..fb5e5d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/VERSION b/VERSION
index 9c4b65b..4d714e7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.7.50-beta
+0.7.60-beta
diff --git a/apps/authelia/template.json b/apps/authelia/template.json
index 2a9652c..c5f0ab0 100644
--- a/apps/authelia/template.json
+++ b/apps/authelia/template.json
@@ -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
}
]
}
diff --git a/apps/baby-buddy/template.json b/apps/baby-buddy/template.json
index ca90458..d7d6b69 100644
--- a/apps/baby-buddy/template.json
+++ b/apps/baby-buddy/template.json
@@ -77,7 +77,9 @@
"description": "Willekeurige waarde voor sessieversleuteling; gebruik de dobbelsteenknop",
"config": {
"placeholder": "willekeurige-lange-string"
- }
+ },
+ "generate": "auto",
+ "secret": true
},
{
"name": "allowed_hosts",
diff --git a/apps/baserow/template.json b/apps/baserow/template.json
index 7e746b0..3b3a23f 100644
--- a/apps/baserow/template.json
+++ b/apps/baserow/template.json
@@ -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"
}
]
}
diff --git a/apps/bookstack/template.json b/apps/bookstack/template.json
index 612e86a..974791b 100644
--- a/apps/bookstack/template.json
+++ b/apps/bookstack/template.json
@@ -78,7 +78,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
- "advanced": true
+ "advanced": true,
+ "generate": "auto",
+ "secret": true
},
{
"name": "app_url",
diff --git a/apps/firefly-iii/template.json b/apps/firefly-iii/template.json
index cc4ab3c..a2d42c0 100644
--- a/apps/firefly-iii/template.json
+++ b/apps/firefly-iii/template.json
@@ -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
}
]
}
diff --git a/apps/freshrss/template.json b/apps/freshrss/template.json
index 49f954b..384902d 100644
--- a/apps/freshrss/template.json
+++ b/apps/freshrss/template.json
@@ -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
}
]
}
diff --git a/apps/ghost/template.json b/apps/ghost/template.json
index 0628192..dfc2ecd 100644
--- a/apps/ghost/template.json
+++ b/apps/ghost/template.json
@@ -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."
}
]
}
diff --git a/apps/gotify/template.json b/apps/gotify/template.json
index 56f4010..47bb9b4 100644
--- a/apps/gotify/template.json
+++ b/apps/gotify/template.json
@@ -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."
}
]
}
diff --git a/apps/grafana/template.json b/apps/grafana/template.json
index dd89dd8..2dab580 100644
--- a/apps/grafana/template.json
+++ b/apps/grafana/template.json
@@ -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."
}
]
}
diff --git a/apps/hedgedoc/template.json b/apps/hedgedoc/template.json
index e4eba68..bf9dff5 100644
--- a/apps/hedgedoc/template.json
+++ b/apps/hedgedoc/template.json
@@ -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"
}
]
}
diff --git a/apps/homepage/template.json b/apps/homepage/template.json
index 14f1715..b76127e 100644
--- a/apps/homepage/template.json
+++ b/apps/homepage/template.json
@@ -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."
}
]
}
diff --git a/apps/immich/template.json b/apps/immich/template.json
index a386079..bd997e9 100644
--- a/apps/immich/template.json
+++ b/apps/immich/template.json
@@ -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."
}
]
}
diff --git a/apps/karakeep/template.json b/apps/karakeep/template.json
index ad850cd..3ab8ad3 100644
--- a/apps/karakeep/template.json
+++ b/apps/karakeep/template.json
@@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
- "advanced": true
+ "advanced": true,
+ "generate": "auto",
+ "secret": true
},
{
"name": "openai_key",
diff --git a/apps/keycloak/template.json b/apps/keycloak/template.json
index 11b11da..ae0f5ab 100644
--- a/apps/keycloak/template.json
+++ b/apps/keycloak/template.json
@@ -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
}
]
}
diff --git a/apps/kimai/template.json b/apps/kimai/template.json
index 7059562..70bdfec 100644
--- a/apps/kimai/template.json
+++ b/apps/kimai/template.json
@@ -62,7 +62,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
- "advanced": true
+ "advanced": true,
+ "generate": "auto",
+ "secret": true
},
{
"name": "admin_email",
diff --git a/apps/kopia/template.json b/apps/kopia/template.json
index 8b1163f..c654386 100644
--- a/apps/kopia/template.json
+++ b/apps/kopia/template.json
@@ -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."
}
]
}
diff --git a/apps/metube/template.json b/apps/metube/template.json
index be5e2e4..fd1211f 100644
--- a/apps/metube/template.json
+++ b/apps/metube/template.json
@@ -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",
diff --git a/apps/miniflux/template.json b/apps/miniflux/template.json
index 0a027ac..e3be5ed 100644
--- a/apps/miniflux/template.json
+++ b/apps/miniflux/template.json
@@ -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."
}
]
}
diff --git a/apps/ntfy/template.json b/apps/ntfy/template.json
index 39d7dce..dec4100 100644
--- a/apps/ntfy/template.json
+++ b/apps/ntfy/template.json
@@ -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",
diff --git a/apps/open-webui/template.json b/apps/open-webui/template.json
index 47272d5..813b696 100644
--- a/apps/open-webui/template.json
+++ b/apps/open-webui/template.json
@@ -74,7 +74,9 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
- }
+ },
+ "generate": "auto",
+ "secret": true
}
]
}
diff --git a/apps/paperless-ngx/template.json b/apps/paperless-ngx/template.json
index 4edf41a..0be770a 100644
--- a/apps/paperless-ngx/template.json
+++ b/apps/paperless-ngx/template.json
@@ -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."
}
]
},
diff --git a/apps/photoprism/template.json b/apps/photoprism/template.json
index 1639cd1..76d6305 100644
--- a/apps/photoprism/template.json
+++ b/apps/photoprism/template.json
@@ -79,7 +79,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
- "advanced": true
+ "advanced": true,
+ "generate": "auto",
+ "secret": true
}
]
}
diff --git a/apps/planka/template.json b/apps/planka/template.json
index 932d396..e687c66 100644
--- a/apps/planka/template.json
+++ b/apps/planka/template.json
@@ -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."
}
]
}
diff --git a/apps/prometheus/files/compose.yaml b/apps/prometheus/files/compose.yaml
index 44b4868..0fa87ea 100644
--- a/apps/prometheus/files/compose.yaml
+++ b/apps/prometheus/files/compose.yaml
@@ -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 >>
diff --git a/apps/searxng/template.json b/apps/searxng/template.json
index f611460..ed96257 100644
--- a/apps/searxng/template.json
+++ b/apps/searxng/template.json
@@ -68,7 +68,9 @@
"description": "Willekeurige waarde; gebruik de dobbelsteenknop",
"config": {
"placeholder": "sterk-wachtwoord"
- }
+ },
+ "generate": "auto",
+ "secret": true
}
]
}
diff --git a/apps/shlink/template.json b/apps/shlink/template.json
index af019ba..9c635c4 100644
--- a/apps/shlink/template.json
+++ b/apps/shlink/template.json
@@ -69,7 +69,9 @@
"description": "Gebruik de dobbelsteenknop voor een willekeurige waarde",
"config": {
"placeholder": "sterk-wachtwoord"
- }
+ },
+ "generate": "auto",
+ "secret": true
}
]
}
diff --git a/apps/speedtest-tracker/template.json b/apps/speedtest-tracker/template.json
index b49b92f..35e4b58 100644
--- a/apps/speedtest-tracker/template.json
+++ b/apps/speedtest-tracker/template.json
@@ -88,7 +88,9 @@
"description": "Willekeurige 32-tekens encryptiesleutel, bijv. base64:xxxx...",
"config": {
"placeholder": "base64:willekeurige-string-van-32-tekens"
- }
+ },
+ "generate": "auto",
+ "secret": true
}
]
}
diff --git a/apps/tandoor/template.json b/apps/tandoor/template.json
index fee7b86..89337c8 100644
--- a/apps/tandoor/template.json
+++ b/apps/tandoor/template.json
@@ -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
}
]
}
diff --git a/apps/teslamate/template.json b/apps/teslamate/template.json
index 43bf46a..24ddaab 100644
--- a/apps/teslamate/template.json
+++ b/apps/teslamate/template.json
@@ -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",
diff --git a/apps/unifi-network/template.json b/apps/unifi-network/template.json
index 0498475..fbc0fcb 100644
--- a/apps/unifi-network/template.json
+++ b/apps/unifi-network/template.json
@@ -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."
}
]
}
diff --git a/apps/vaultwarden/template.json b/apps/vaultwarden/template.json
index 94f40b8..7f7b21c 100644
--- a/apps/vaultwarden/template.json
+++ b/apps/vaultwarden/template.json
@@ -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
}
]
}
diff --git a/apps/vikunja/template.json b/apps/vikunja/template.json
index c6de82a..e0fe431 100644
--- a/apps/vikunja/template.json
+++ b/apps/vikunja/template.json
@@ -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."
}
]
}
diff --git a/apps/wallabag/template.json b/apps/wallabag/template.json
index 879608a..b3e8849 100644
--- a/apps/wallabag/template.json
+++ b/apps/wallabag/template.json
@@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
- "advanced": true
+ "advanced": true,
+ "generate": "auto",
+ "secret": true
},
{
"name": "app_url",
diff --git a/apps/wikijs/template.json b/apps/wikijs/template.json
index 75be0e2..c21e152 100644
--- a/apps/wikijs/template.json
+++ b/apps/wikijs/template.json
@@ -61,7 +61,9 @@
"config": {
"placeholder": "sterk-wachtwoord"
},
- "advanced": true
+ "advanced": true,
+ "generate": "auto",
+ "secret": true
}
]
}
diff --git a/docs/apps-maken.md b/docs/apps-maken.md
index e0a57f8..9acd889 100644
--- a/docs/apps-maken.md
+++ b/docs/apps-maken.md
@@ -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
diff --git a/server-up/app.py b/server-up/app.py
index 297966d..eb6d5ac 100644
--- a/server-up/app.py
+++ b/server-up/app.py
@@ -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:
diff --git a/server-up/core/boilerplates.py b/server-up/core/boilerplates.py
index 7754593..9deb724 100644
--- a/server-up/core/boilerplates.py
+++ b/server-up/core/boilerplates.py
@@ -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):
diff --git a/server-up/templates/index.html b/server-up/templates/index.html
index f386ebf..29e1daa 100644
--- a/server-up/templates/index.html
+++ b/server-up/templates/index.html
@@ -1482,7 +1482,7 @@ tailwind.config = {
-
@@ -1543,7 +1543,7 @@ tailwind.config = {
-
@@ -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 {
diff --git a/tests/test_app_templates.py b/tests/test_app_templates.py
new file mode 100644
index 0000000..cb85755
--- /dev/null
+++ b/tests/test_app_templates.py
@@ -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")
diff --git a/tests/test_verplichte_velden.py b/tests/test_verplichte_velden.py
new file mode 100644
index 0000000..3120cd5
--- /dev/null
+++ b/tests/test_verplichte_velden.py
@@ -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