401 lines
16 KiB
Python
401 lines
16 KiB
Python
"""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())
|