All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m34s
Invulmenu:
- Vaste stappen (Basis, Verbinden, Instellingen, Toegang, Controleren) in
plaats van één lange lijst. Eén stap per groep werkt niet: de mediane app
heeft één groep, arr-stack eenentwintig. Lege stappen worden overgeslagen.
- Volgende controleert de verplichte velden van die stap; die melding kwam
eerder pas bij het installeren.
- Geavanceerde opties achter één schakelaar die op elke stap zichtbaar is,
plus 'Alles op één pagina' voor het oude gedrag. Beide onthouden.
Geheimen naar .env:
- compose_transform.geheimen_naar_env() vervangt geheime waarden door
${NAAM} en schrijft ze naar .env met rechten 0600. Werkt ook midden in
een database-URL. Poorten en paden blijven leesbaar in compose.
- .serverup.json bevat geen geheimen meer en krijgt ook 0600; daar stonden
ze wereldleesbaar in. Het herconfiguratieformulier leest ze terug uit
.env via een bewaarde veld-naar-sleutel-koppeling.
- docs/beveiliging.md legt uit wat dit niet oplost: docker inspect toont de
waarde nog steeds.
Bewerken:
- Laatste stap toont het gerenderde resultaat met jouw waarden en is
bewerkbaar; compose_override/env_override gaan mee bij het installeren.
Ongeldige YAML wordt geweigerd voordat er een map bestaat.
- De bestaande editor heeft tabbladen (compose/.env) en biedt herstarten na
opslaan. De .env-PUT weigert een regel zonder '=' en logt in audit.
Inloggegevens:
- /api/stacks/<n>/credentials plus paneel op de stackkaart: adres,
gebruikersnaam, wachtwoord achter een oog-knop, kopieerknop. Alleen voor
beheerders, elk bekijken komt in het auditlog.
- Nieuw 'credential'-kenmerk in template.json met naamherkenning als
terugval, zodat de bestaande 96 sjablonen meteen werken.
Herstel van een regressie uit v0.7.60: acht inlogwachtwoorden hadden hun
genereerknop verloren toen die aan 'generate' werd gehangen. Terug, behalve
waar een verzonnen waarde fout is (WireGuard-sleutel, externe tokens).
De menulogica wordt getest door de echte component uit index.html in Node uit
te voeren; slaat zichzelf over waar node ontbreekt. 2295 tests groen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
239 lines
9.2 KiB
Python
239 lines
9.2 KiB
Python
"""Geheimen horen in `.env`, niet in het compose-bestand.
|
|
|
|
Compose vult `${NAAM}` in bij het inlezen, dus functioneel verandert er niets.
|
|
Wat je wint: het compose-bestand is te delen en te committen, en alle geheimen
|
|
staan op één plek die met 0600 weggeschreven kan worden — tot v0.7.60 stonden
|
|
ze zowel in `docker-compose.yml` als wereldleesbaar in `.serverup.json`.
|
|
"""
|
|
import json
|
|
import stat
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
|
|
|
from conftest import login
|
|
|
|
VELDEN = [{"name": "secret_key", "secret": True},
|
|
{"name": "db_password", "secret": True},
|
|
{"name": "timezone", "secret": False},
|
|
{"name": "port", "secret": False}]
|
|
WAARDEN = {"secret_key": "Xq2mB7vK9pL4nR8t", "db_password": "aB3dE6gH9jK2mN5p",
|
|
"timezone": "Europe/Amsterdam", "port": 8000}
|
|
|
|
COMPOSE = """services:
|
|
app:
|
|
image: voorbeeld:latest
|
|
ports:
|
|
- "8000:8000"
|
|
environment:
|
|
- APP_SECRET_KEY=Xq2mB7vK9pL4nR8t
|
|
- APP_DBPASS=aB3dE6gH9jK2mN5p
|
|
- TZ=Europe/Amsterdam
|
|
db:
|
|
image: postgres:16
|
|
environment:
|
|
POSTGRES_PASSWORD: aB3dE6gH9jK2mN5p
|
|
worker:
|
|
image: worker:latest
|
|
environment:
|
|
- DATABASE_URL=postgres://app:aB3dE6gH9jK2mN5p@db:5432/app
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def ct(env):
|
|
from core import compose_transform
|
|
return compose_transform
|
|
|
|
|
|
# ── De transformatie ─────────────────────────────────────────────────────────
|
|
|
|
def test_geheimen_verdwijnen_uit_compose(ct):
|
|
nieuw, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
assert "Xq2mB7vK9pL4nR8t" not in nieuw
|
|
assert "aB3dE6gH9jK2mN5p" not in nieuw
|
|
assert set(env_regels.values()) == {"Xq2mB7vK9pL4nR8t", "aB3dE6gH9jK2mN5p"}
|
|
|
|
|
|
def test_niet_geheime_waarden_blijven_leesbaar(ct):
|
|
"""Poorten, paden en tijdzone horen zichtbaar te blijven; anders moet je
|
|
twee bestanden naast elkaar lezen om te snappen wat er draait."""
|
|
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
assert "Europe/Amsterdam" in nieuw
|
|
assert "8000" in nieuw
|
|
|
|
|
|
def test_sleutelnaam_komt_uit_de_omgevingsvariabele(ct):
|
|
"""`.env` moet leesbaar zijn: APP_SECRET_KEY zegt meer dan SECRET_KEY_1."""
|
|
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
assert "APP_SECRET_KEY" in env_regels
|
|
|
|
|
|
def test_dezelfde_waarde_krijgt_een_sleutel(ct):
|
|
"""Het databasewachtwoord staat op drie plekken; dat wordt één regel."""
|
|
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
assert list(env_regels.values()).count("aB3dE6gH9jK2mN5p") == 1
|
|
|
|
|
|
def test_geheim_midden_in_een_url(ct):
|
|
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
doc = yaml.safe_load(nieuw)
|
|
url = doc["services"]["worker"]["environment"][0]
|
|
assert url.startswith("DATABASE_URL=postgres://app:${")
|
|
assert url.endswith("@db:5432/app")
|
|
|
|
|
|
def test_koppeling_wijst_veld_naar_sleutel(ct):
|
|
"""Zonder die koppeling kunnen we de waarde later niet exact terugvinden."""
|
|
_, env_regels, koppeling = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
assert set(koppeling) == {"secret_key", "db_password"}
|
|
for veld, sleutel in koppeling.items():
|
|
assert env_regels[sleutel] == WAARDEN[veld]
|
|
|
|
|
|
def test_zonder_geheimen_verandert_er_niets(ct):
|
|
nieuw, env_regels, koppeling = ct.geheimen_naar_env(
|
|
COMPOSE, [{"name": "port", "secret": False}], {"port": 8000})
|
|
assert nieuw == COMPOSE
|
|
assert env_regels == {} and koppeling == {}
|
|
|
|
|
|
def test_leeg_geheim_wordt_overgeslagen(ct):
|
|
"""Anders zou een lege waarde overal in het bestand 'gevonden' worden."""
|
|
nieuw, env_regels, _ = ct.geheimen_naar_env(
|
|
COMPOSE, [{"name": "leeg", "secret": True}], {"leeg": ""})
|
|
assert nieuw == COMPOSE and env_regels == {}
|
|
|
|
|
|
def test_ongeldige_yaml_geeft_een_nette_fout(ct):
|
|
with pytest.raises(ct.TransformError):
|
|
ct.geheimen_naar_env("dit: [is: geen: yaml", VELDEN, WAARDEN)
|
|
|
|
|
|
def test_compose_blijft_geldig(ct):
|
|
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
|
doc = yaml.safe_load(nieuw)
|
|
assert set(doc["services"]) == {"app", "db", "worker"}
|
|
|
|
|
|
# ── env samenvoegen ──────────────────────────────────────────────────────────
|
|
|
|
def test_env_samenvoegen_behoudt_bestaande_regels(ct):
|
|
uit = ct.env_samenvoegen("BIND=0.0.0.0\nPORT=5000\n", {"DB_PASS": "x"})
|
|
assert "BIND=0.0.0.0" in uit and "PORT=5000" in uit and "DB_PASS=x" in uit
|
|
|
|
|
|
def test_env_samenvoegen_vervangt_zonder_te_verdubbelen(ct):
|
|
uit = ct.env_samenvoegen("DB_PASS=oud\nPORT=5000\n", {"DB_PASS": "nieuw"})
|
|
assert uit.count("DB_PASS=") == 1
|
|
assert "DB_PASS=nieuw" in uit and "oud" not in uit
|
|
|
|
|
|
def test_env_samenvoegen_houdt_commentaar(ct):
|
|
uit = ct.env_samenvoegen("# van de installatie\nPORT=5000\n", {"X": "1"})
|
|
assert "# van de installatie" in uit
|
|
|
|
|
|
# ── Rechten ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_env_wordt_met_beperkte_rechten_geschreven(env, tmp_path):
|
|
"""Hier staan de wachtwoorden in; standaard maakt Python het bestand
|
|
wereldleesbaar."""
|
|
from core import docker
|
|
docker.write_env(tmp_path, "DB_PASS=geheim\n")
|
|
modus = stat.S_IMODE((tmp_path / ".env").stat().st_mode)
|
|
assert modus == 0o600, oct(modus)
|
|
|
|
|
|
# ── Van begin tot eind ───────────────────────────────────────────────────────
|
|
|
|
def _bron(env, naam="geheimapp"):
|
|
from core import git
|
|
wortel = git.cache_dir("server-up") / "apps" / naam
|
|
(wortel / "files").mkdir(parents=True, exist_ok=True)
|
|
(wortel / "template.json").write_text(json.dumps({
|
|
"kind": "compose", "metadata": {"name": naam},
|
|
"variables": [{"title": "Algemeen", "items": [
|
|
{"name": "service_name", "type": "str", "title": "Naam",
|
|
"default": naam, "required": True},
|
|
{"name": "timezone", "type": "str", "title": "Tijdzone",
|
|
"default": "Europe/Amsterdam"},
|
|
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
|
|
"default": "", "required": True, "generate": "auto"},
|
|
]}]}), encoding="utf-8")
|
|
(wortel / "files" / "compose.yaml").write_text(
|
|
"services:\n"
|
|
" << service_name >>:\n"
|
|
" image: nginx\n"
|
|
" environment:\n"
|
|
" - TZ=<< timezone >>\n"
|
|
" - DB_PASSWORD=<< db_password >>\n", encoding="utf-8")
|
|
return wortel
|
|
|
|
|
|
def _wacht(client, jid, tijdslimiet=10):
|
|
einde = time.time() + tijdslimiet
|
|
while time.time() < einde:
|
|
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
|
if d.get("status") in ("done", "error"):
|
|
return d
|
|
time.sleep(0.05)
|
|
return {}
|
|
|
|
|
|
def test_installatie_zet_het_geheim_in_env(client, env):
|
|
csrf = login(client)
|
|
_bron(env)
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "geheimapp", "repo_id": "server-up",
|
|
"instance": "ga", "values": {"db_password": "zeergeheim123"}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
_wacht(client, r.get_json()["job_id"])
|
|
|
|
d = env["lib"] / "ga"
|
|
compose = (d / "docker-compose.yml").read_text()
|
|
assert "zeergeheim123" not in compose, compose
|
|
assert "${" in compose
|
|
assert "zeergeheim123" in (d / ".env").read_text()
|
|
# De tijdzone hoort gewoon leesbaar te blijven.
|
|
assert "Europe/Amsterdam" in compose
|
|
|
|
|
|
def test_installatie_bewaart_geen_geheim_in_de_metadata(client, env):
|
|
"""`.serverup.json` stond wereldleesbaar vol wachtwoorden."""
|
|
csrf = login(client)
|
|
_bron(env)
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "geheimapp", "repo_id": "server-up",
|
|
"instance": "gb", "values": {"db_password": "zeergeheim123"}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht(client, r.get_json()["job_id"])
|
|
|
|
mf = env["lib"] / "gb" / ".serverup.json"
|
|
ruw = mf.read_text()
|
|
assert "zeergeheim123" not in ruw, ruw
|
|
meta = json.loads(ruw)
|
|
assert meta["secret_fields"] == ["db_password"]
|
|
assert meta["secret_keys"]["db_password"] == "DB_PASSWORD"
|
|
assert stat.S_IMODE(mf.stat().st_mode) == 0o600
|
|
|
|
|
|
def test_formulier_toont_het_geheim_weer_bij_wijzigen(client, env):
|
|
"""Anders staat het veld leeg en wist opslaan het wachtwoord."""
|
|
csrf = login(client)
|
|
_bron(env)
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "geheimapp", "repo_id": "server-up",
|
|
"instance": "gc", "values": {"db_password": "zeergeheim123"}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht(client, r.get_json()["job_id"])
|
|
|
|
d = client.get("/api/stacks/gc/config").get_json()
|
|
assert d["ok"] is True
|
|
assert d["values"]["db_password"] == "zeergeheim123"
|