Files
UmbrelApps/tests/test_server_start_zonder_certificaat.py
T

401 lines
16 KiB
Python
Raw Normal View History

2026-08-25 16:27:57 +02:00
"""Toetst dat de pagina omhoog komt zonder certificaat.
Waarom deze test bestaat. In 0.0.3 startte de app niet: het stream-blok van
nginx had een 'listen 50022 ssl' met een include naar de cert.conf van de agent,
en nginx weigert te starten als dat certificaat er niet is. Daardoor kwam ook de
web-UI niet omhoog, en dat is precies de pagina waarop je een certificaat kiest.
Wie geen reverse proxy draait, of twee kandidaten heeft, zag dus niets en kon
niets. De symptomen wezen ergens anders heen: de app_proxy meldde alleen dat de
server niet te bereiken was.
De reparatie is een verplaatsing, en die is niet aan de code af te lezen: het
stream-blok staat nu in stream.conf.template en wordt door het command-blok van
de compose pas in /var/lib/gate/tls/ gezet als cert.conf bestaat. nginx.conf
haalt die map op met een jokerteken, en een jokerteken dat niets matcht is voor
nginx geen fout. Eén iemand die dat blok "netjes" terugzet in nginx.conf en de
klem is terug, zonder dat een van de andere toetsen iets merkt.
Draaien:
python tests/test_server_start_zonder_certificaat.py
Er staan inmiddels een paar toetsen bij die niet over het starten gaan maar wel
over dezelfde soort fout: een die niets meldt en pas opvalt als iemand het toevallig
ziet. De verbindingsteller die stil nul telt, een mount die weer alleen-lezen wordt,
en twee taglines die uit elkaar lopen. Ze staan hier en niet in een derde bestand,
want de suite heeft geen runner en een bestand dat niemand aanroept toetst niets.
"""
import sys
sys.dont_write_bytecode = True
import os # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
APP = os.path.join(HERE, os.pardir, "whatsnext-electrum-gate")
NGINX_CONF = os.path.join(APP, "nginx.conf.template")
STREAM_CONF = os.path.join(APP, "stream.conf.template")
COMPOSE = os.path.join(APP, "docker-compose.yml")
MANIFEST = os.path.join(APP, "umbrel-app.yml")
PAGINA = os.path.join(APP, "index.html.template")
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def lees(pad):
with open(pad, "r", encoding="utf-8") as f:
return f.read()
def zonder_commentaar(tekst):
"""De regels die nginx daadwerkelijk uitvoert.
Zonder dit zou elke toets hieronder afgaan op de uitleg erboven, en die
noemt juist de dingen die er niet meer mogen staan.
"""
regels = []
for regel in tekst.splitlines():
kaal = regel.strip()
if not kaal or kaal.startswith("#"):
continue
regels.append(kaal)
return regels
def test_pagina_hangt_niet_aan_een_certificaat(u):
"""Geen TLS-directive in nginx.conf, want die blokkeert de start."""
regels = zonder_commentaar(lees(NGINX_CONF))
u.check("nginx.conf heeft geen listen met ssl",
not any("listen" in r and "ssl" in r for r in regels),
"een 'listen ssl' zonder certificaat laat nginx niet starten, "
"en dan is er ook geen pagina om er een te kiezen")
u.check("nginx.conf includet cert.conf niet rechtstreeks",
not any("cert.conf" in r for r in regels),
"een include van een bestand dat er nog niet is, is een startfout")
u.check("nginx.conf haalt het stream-blok met een jokerteken op",
any(r.startswith("include") and "/var/lib/gate/tls/*" in r
for r in regels),
"zonder jokerteken is een lege map alsnog een startfout")
u.check("de pagina zelf luistert nog wel",
any(r.startswith("listen 80") for r in regels))
def test_stream_blok_staat_apart(u):
"""Het TLS-deel is compleet, en het is een template."""
u.check("stream.conf.template bestaat", os.path.isfile(STREAM_CONF),
"anders komt het bij een update niet mee: alleen *.template, "
"docker-compose.yml, exports.sh, torrc, hooks en umbrel-app.yml "
"worden ververst")
if not os.path.isfile(STREAM_CONF):
return
tekst = lees(STREAM_CONF)
regels = zonder_commentaar(tekst)
u.check("stream.conf luistert op 50022 met ssl",
any("listen 50022 ssl" in r for r in regels))
u.check("stream.conf includet de cert.conf van de agent",
any("include /var/lib/gate/cert.conf" in r for r in regels))
u.check("stream.conf includet het log_format",
any("include /var/lib/gate/stream-log.conf" in r for r in regels))
u.check("stream.conf verwijst naar de backend uit de omgeving",
any("proxy_pass ${APP_ELECTRS_NODE_IP}" in r for r in regels))
# Dezelfde architectuurregel als voor de andere templates: umbreld vult elke
# accolade-variabele in, dus een nginx-variabele met een dollarteken wordt
# hier stil leeggemaakt. Vandaar dat het log_format uit een ander bestand
# komt.
losse_dollars = []
for regel in regels:
pos = regel.find("$")
while pos >= 0:
if regel[pos + 1:pos + 2] != "{":
losse_dollars.append(regel)
break
pos = regel.find("$", pos + 1)
u.check("stream.conf heeft geen nginx-variabelen",
not losse_dollars,
"de template-invulling maakt die leeg: %r" % losse_dollars[:2])
def test_compose_wacht_niet_op_de_agent(u):
"""Het command-blok start nginx altijd, en zet TLS erbij als het kan."""
tekst = lees(COMPOSE)
regels = zonder_commentaar(tekst)
u.check("geen wachtlus op cert.conf",
not any("! -f /var/lib/gate/cert.conf" in r and "while" in r
for r in regels),
"dat is precies de klem uit 0.0.3: geen certificaat, geen nginx, "
"geen pagina, dus geen manier om een certificaat te kiezen")
u.check("stream.conf is in de server gemount",
any("stream.conf:/etc/nginx/stream.conf" in r for r in regels),
"anders is er niets om in /var/lib/gate/tls/ te zetten")
u.check("het stream-blok wordt weggezet als cert.conf bestaat",
any("cp /etc/nginx/stream.conf /var/lib/gate/tls/" in r
for r in regels))
u.check("en weggehaald als cert.conf er niet is",
any("rm -f /var/lib/gate/tls/stream.conf" in r for r in regels),
"een ingetrokken keuze moet de poort ook echt sluiten")
u.check("een mislukte herlading breekt de wachtlus niet af",
any("if ! nginx -s reload" in r for r in regels),
"onder 'set -e' zou de lus verdwijnen en daarna pikt niets meer "
"een wijziging op")
def test_uploadpad(u):
"""De drie dingen buiten de agent die uploaden mogelijk maken.
Alle drie kunnen ze stil wegvallen: een mount die weer `:ro` wordt geeft een
foutmelding pas als iemand iets uploadt, en een te kleine limiet in nginx
geeft een 413 die niets uitlegt.
"""
compose = zonder_commentaar(lees(COMPOSE))
nginx = zonder_commentaar(lees(NGINX_CONF))
eigen = [r for r in compose if "/certs/own" in r and r.startswith("-")]
u.check("de eigen certificaatmap is twee keer gemount",
len(eigen) == 2, "gevonden: %r" % eigen)
u.check("en precies één daarvan is beschrijfbaar",
len([r for r in eigen if not r.endswith(":ro")]) == 1,
"zonder :ro kan de agent niets neerzetten, met :ro overal ook niet: %r"
% eigen)
u.check("GATE_UPLOAD_DIR staat in de omgeving van de agent",
any(r.startswith("GATE_UPLOAD_DIR:") for r in compose),
"leeg of afwezig betekent: uploaden staat uit")
u.check("nginx heeft een eigen locatie voor de upload",
any("location = /api/certificate/upload" in r for r in nginx))
# De limiet moet groter zijn dan die van /api/, anders kapt nginx het verzoek
# af voordat de agent er iets over kan zeggen.
limieten = []
for regel in nginx:
if regel.startswith("client_max_body_size"):
limieten.append(regel.rstrip(";").split()[-1])
u.check("en een grotere limiet dan het gewone api-pad",
len(limieten) == 2 and limieten[0] != limieten[1],
"gevonden limieten: %r" % limieten)
def test_verbindingsteller(u):
"""De teller in de nginx-container, en de poort in hex.
Hoort hier omdat het om hetzelfde `command`-blok gaat. Het gevaarlijke deel
is de hex: de teller zoekt in /proc/net/tcp op de poort in hexadecimale vorm,
en verandert de TLS-poort ooit, dan telt hij stil nul. Een teller die altijd
nul zegt, ziet eruit als "geen wallet verbonden" en niet als een fout.
"""
tekst = lees(COMPOSE)
regels = zonder_commentaar(tekst)
teller = [r for r in regels if "/proc/net/tcp" in r]
u.check("de teller leest /proc/net/tcp", bool(teller),
"dat is de enige plek waar de open verbindingen van deze container "
"staan; de agent zit in een andere netwerk-namespace")
u.check("en schrijft het aantal weg voor de agent",
any("/var/lib/gate/sessions" in r for r in regels))
# De poort uit de omgeving van de agent is de waarheid; de teller moet
# dezelfde poort in hex zoeken.
poorten = [r for r in regels if r.startswith("GATE_TLS_PORT:")]
u.check("GATE_TLS_PORT staat in de compose", len(poorten) == 1,
"gevonden: %r" % poorten)
if not poorten or not teller:
return
poort = int(poorten[0].split(":", 1)[1].strip().strip('"'))
hexpoort = "%X" % poort
# Over de regels heen zoeken, want de awk-regel staat achter een
# backslash-vervolg en dan valt de hex op een eigen regel.
u.check("de teller zoekt de TLS-poort in hex (%d is %s)" % (poort, hexpoort),
(":" + hexpoort) in " ".join(regels),
"nergens in het command-blok staat :%s, dus telt de teller nul"
% hexpoort)
def test_data_onder_data(u):
"""Alles wat de app zelf schrijft staat onder `data/`.
De conventie van de officiele appstore, afgelezen aan echte apps: electrs
mount `${APP_DATA_DIR}/data/electrs`, mempool `${APP_DATA_DIR}/data`. Deze app
had `runtime/` en `certs/` naast de templates staan. Verplaatst op 20-08-2026
met het oog op publicatie.
Waarom er een toets op staat: bij de volgende mount die iemand toevoegt is dit
precies het detail dat je vergeet, en niets gaat er stuk van. Het valt pas op
bij het inleveren.
"""
regels = zonder_commentaar(lees(COMPOSE))
mounts = [r[1:].strip() for r in regels
if r.startswith("-") and "${APP_DATA_DIR}" in r]
u.check("er zijn mounts uit de app-datamap", bool(mounts))
fout = []
for mount in mounts:
host = mount.strip('"').split(":")[0].replace("${APP_DATA_DIR}/", "")
# Een bestand naast de compose mag: dat zijn de ingevulde templates en het
# icoon, en die hóren daar omdat de whitelist ze daar verft. Een map die de
# app zelf vult, hoort onder data/.
if "." in os.path.basename(host):
continue
if not host.startswith("data/"):
fout.append(host)
u.check("elke map die de app zelf vult staat onder data/",
not fout, "deze niet: %r" % fout)
u.check("de gedeelde toestand staat onder data/",
any("${APP_DATA_DIR}/data/runtime:" in m for m in mounts))
u.check("de eigen certificaatmap staat onder data/",
any("${APP_DATA_DIR}/data/certs:" in m for m in mounts))
# En het manifest moet die paden ook noemen, anders wijst backupIgnore naar
# iets wat niet bestaat en gaat de sessielog alsnog mee in elke back-up.
manifest = lees(MANIFEST)
u.check("backupIgnore staat in het manifest", "backupIgnore:" in manifest)
for pad in ("data/runtime/stream.log", "data/runtime/status.json"):
u.check("backupIgnore noemt %s" % pad, pad in manifest)
# De volgorde die de packaging-documentatie van de officiele appstore voorschrijft.
# Wat de spec niet noemt, zoals icon en backupIgnore, hoort daarachter en niet
# ertussen: dan is de kop van het manifest letterlijk goed.
MANIFEST_ORDE = [
"manifestVersion", "id", "category", "name", "version", "tagline",
"description", "releaseNotes", "developer", "website", "dependencies",
"repo", "support", "port", "gallery", "path",
]
def manifest_velden():
"""De veldnamen op het hoogste niveau, in de volgorde van het bestand."""
velden = []
for regel in lees(MANIFEST).splitlines():
if not regel or regel[0] in " #-":
continue
if ":" in regel:
velden.append(regel.split(":", 1)[0])
return velden
def test_manifest_volgorde(u):
"""De velden staan in de voorgeschreven volgorde.
Geen smaak: de officiele appstore schrijft deze volgorde voor. Een toets
hierop omdat een nieuw veld standaard onderaan of middenin belandt, en er
niets van stukgaat; het valt pas op bij het inleveren.
"""
velden = manifest_velden()
kern = [v for v in velden if v in MANIFEST_ORDE]
ontbreekt = [v for v in MANIFEST_ORDE if v not in velden]
u.check("alle voorgeschreven velden staan in het manifest",
not ontbreekt, "mist: %r" % ontbreekt)
u.check("en in de voorgeschreven volgorde",
kern == MANIFEST_ORDE, "de volgorde is nu: %r" % kern)
# De extra's horen achter de voorgeschreven reeks. Zo is de kop van het
# bestand letterlijk op orde en hoeft er bij inlevering alleen iets weg.
if kern == MANIFEST_ORDE and velden:
laatste_kern = velden.index(MANIFEST_ORDE[-1])
extra_ervoor = [v for v in velden[:laatste_kern] if v not in MANIFEST_ORDE]
u.check("en wat de spec niet noemt staat erachter",
not extra_ervoor, "deze staan ertussen: %r" % extra_ervoor)
u.check("de lege standaard-inloggegevens zijn eruit",
"defaultUsername" not in velden and "defaultPassword" not in velden,
"deze app heeft geen eigen inlog, dus een leeg veld zegt niets")
def test_releasenotes_hebben_een_geschiedenis(u):
"""Eén verhaal over deze versie, dan één regel per eerdere versie.
Deze toets bestaat om een echte fout: drie versies achter elkaar kwam er een
nieuwe kop bovenop de release notes terwijl de oude tekst eronder bleef staan.
In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer dezelfde
regel over 0.0.4. Dat is zichtbare tekst in de appstore, en niemand leest zijn
eigen release notes nog een keer na.
"""
tekst = lees(MANIFEST)
u.check("er staat precies een keer een geschiedenis-kop",
tekst.count("Earlier releases:") == 1,
"gevonden: %d keer" % tekst.count("Earlier releases:"))
# Elke eerdere versie mag maar een keer genoemd worden.
dubbel = []
for versie in ("0.0.4", "0.0.5", "0.0.6", "0.0.7", "0.0.8"):
aantal = tekst.count("\n " + versie + " ")
if aantal > 1:
dubbel.append((versie, aantal))
u.check("en geen versie wordt twee keer beschreven",
not dubbel, "dubbel: %r" % dubbel)
def test_tagline_is_overal_dezelfde(u):
"""De regel onder de app-naam staat op twee plekken en moet gelijk zijn.
Besloten door de gebruiker op 20-08-2026, nadat hij zag dat de winkel en de
pagina iets anders zeiden. Eén app, één regel. Deze toets bestaat omdat de
twee plekken ver uit elkaar staan: niemand die de tagline in het manifest
aanpast, opent daarna de pagina.
"""
tagline = None
for regel in lees(MANIFEST).splitlines():
if regel.startswith("tagline:"):
tagline = regel.split(":", 1)[1].strip().strip('"')
break
u.check("het manifest heeft een tagline", bool(tagline))
if not tagline:
return
u.check("en de pagina gebruikt woordelijk dezelfde regel",
tagline in lees(PAGINA),
"de pagina noemt hem niet: %r" % tagline)
def main():
u = Uitslag()
test_pagina_hangt_niet_aan_een_certificaat(u)
test_stream_blok_staat_apart(u)
test_compose_wacht_niet_op_de_agent(u)
test_verbindingsteller(u)
test_uploadpad(u)
test_data_onder_data(u)
test_manifest_volgorde(u)
test_releasenotes_hebben_een_geschiedenis(u)
test_tagline_is_overal_dezelfde(u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())