"""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 in stream.conf en wordt door entrypoint.sh 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. Sinds 0.1.0 zitten deze bestanden in een eigen image en staan ze in tools/electrum-gate/; tot dan waren het *.template-bestanden in de app-map en was het startscript een command-blok in de compose. De toetsen zijn mee verhuisd en toetsen hetzelfde gedrag, plus wat er bij die verhuizing nieuw kapot kan: de Dockerfile die een bestand niet meeneemt, en de plaatshouders in stream.conf die entrypoint.sh moet invullen. Draaien: python tests/test_server_start_zonder_certificaat.py Er staan 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 import re # noqa: E402 HERE = os.path.dirname(os.path.abspath(__file__)) APP = os.path.join(HERE, os.pardir, "whatsnext-electrum-gate") RECEPT = os.path.join(HERE, os.pardir, "tools", "electrum-gate") NGINX_CONF = os.path.join(RECEPT, "nginx.conf") STREAM_CONF = os.path.join(RECEPT, "stream.conf") ENTRYPOINT = os.path.join(RECEPT, "entrypoint.sh") DOCKERFILE = os.path.join(RECEPT, "Dockerfile") BUILD = os.path.join(RECEPT, "build.sh") PAGINA = os.path.join(RECEPT, "index.html") COMPOSE = os.path.join(APP, "docker-compose.yml") MANIFEST = os.path.join(APP, "umbrel-app.yml") 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 of de shell 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 backend-adres komt uit de omgeving.""" u.check("stream.conf bestaat", os.path.isfile(STREAM_CONF)) if not os.path.isfile(STREAM_CONF): return regels = zonder_commentaar(lees(STREAM_CONF)) 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 heeft zijn log_format zelf", any(r.startswith("log_format gate") for r in regels), "tot 0.0.29 schreef het command-blok dat in een los bestand, omdat " "een dollarteken in een template leeggemaakt werd; dat hoeft niet meer") u.check("en schrijft de sessielog waar de agent hem leest", any(r.startswith("access_log /var/lib/gate/stream.log") for r in regels)) # Het adres van de backend staat niet in dit bestand maar komt uit de # omgeving, en de weg daarheen is een plaatshouder die entrypoint.sh invult. # Geen dollarteken in die plaatshouder: dan kan geen envsubst-stap, van # umbreld of van wie dan ook, hem stil leegmaken. proxy = [r for r in regels if r.startswith("proxy_pass")] u.check("stream.conf heeft precies één proxy_pass", len(proxy) == 1, "gevonden: %r" % proxy) u.check("en die verwijst naar de plaatshouders voor host en poort", proxy and "__GATE_ELECTRUM_HOST__:__GATE_ELECTRUM_PORT__" in proxy[0], "gevonden: %r" % proxy) u.check("stream.conf bevat geen accolade-variabele meer", "${" not in lees(STREAM_CONF), "die wordt sinds de eigen image door niemand meer ingevuld en zou " "letterlijk in de configuratie belanden") def test_entrypoint_wacht_niet_op_de_agent(u): """entrypoint.sh start nginx altijd, en zet TLS erbij als het kan.""" u.check("entrypoint.sh bestaat", os.path.isfile(ENTRYPOINT)) if not os.path.isfile(ENTRYPOINT): return tekst = lees(ENTRYPOINT) regels = zonder_commentaar(tekst) u.check("geen wachtlus op cert.conf", not any("! -f" in r and "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("het stream-blok wordt ingevuld uit de omgeving", any("__GATE_ELECTRUM_HOST__" in r and "GATE_ELECTRUM_HOST" in r for r in regels) and any("__GATE_ELECTRUM_PORT__" in r for r in regels), "anders staat de plaatshouder letterlijk in de proxy_pass") u.check("en wordt uit stream.conf.in gelezen, zoals de Dockerfile hem neerzet", any("/etc/nginx/stream.conf.in" in r for r in regels)) u.check("het stream-blok wordt weggezet als cert.conf bestaat", any(r.startswith("if cp /etc/nginx/stream.conf") and "/tls/" in r for r in regels)) u.check("en weggehaald als cert.conf er niet is", any(r.startswith("rm -f") and "/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") u.check("nginx wordt met exec het hoofdproces", regels and regels[-1].startswith("exec nginx"), "anders blijft de shell PID 1, geeft signalen niet door, en ziet " "Docker een geslaagde afsluiting als nginx omvalt; laatste regel: %r" % (regels[-1] if regels else None)) u.check("het script begint met een shebang", tekst.startswith("#!/bin/sh")) u.check("geen verdubbelde dollartekens meer", "$$" not in "\n".join(regels), "dat was de ontsnapping voor het command-blok in de compose; in een " "gewoon script is $$ het proces-id") def test_dockerfile_neemt_alles_mee(u): """Wat entrypoint.sh en nginx.conf verwachten, zet de Dockerfile ook neer. Dit is de nieuwe klasse fouten van de eigen image: een bestand dat in de repo staat maar niet in de image, en dan faalt de container pas op het apparaat. Geen Docker nodig om dat te zien; de paden staan in de tekst. """ u.check("Dockerfile bestaat", os.path.isfile(DOCKERFILE)) if not os.path.isfile(DOCKERFILE): return regels = zonder_commentaar(lees(DOCKERFILE)) copies = [r for r in regels if r.startswith("COPY ")] def kopieert(bron, doel): return any(bron in r and r.endswith(" " + doel) for r in copies) u.check("nginx.conf gaat naar /etc/nginx/nginx.conf", kopieert("tools/electrum-gate/nginx.conf", "/etc/nginx/nginx.conf")) u.check("stream.conf gaat naar /etc/nginx/stream.conf.in", kopieert("tools/electrum-gate/stream.conf", "/etc/nginx/stream.conf.in"), "entrypoint.sh leest dat pad en vult het in naar stream.conf") u.check("de pagina gaat naar de webroot", kopieert("tools/electrum-gate/index.html", "/usr/share/nginx/html/index.html")) u.check("het icoon komt uit de app-map", kopieert("whatsnext-electrum-gate/icon.png", "/usr/share/nginx/html/icon.png"), "de app-map houdt het origineel omdat het manifest ernaar wijst; " "de image kopieert het, zodat er niet twee exemplaren zijn") u.check("de agent gaat naar /app/agent.py", kopieert("tools/electrum-gate/agent.py", "/app/agent.py"), "de compose start hem op dat pad") u.check("entrypoint.sh gaat mee en wordt uitvoerbaar", kopieert("tools/electrum-gate/entrypoint.sh", "/entrypoint.sh") and any(r.startswith("RUN chmod") and "/entrypoint.sh" in r for r in regels), "git op Windows kent geen uitvoerbit, dus die moet de Dockerfile zetten") u.check("python3 wordt geïnstalleerd", any(r.startswith("RUN apk add") and "python3" in r for r in regels)) u.check("de basisimage is nginx op een vaste tag", any(re.match(r"FROM nginx:\d+\.\d+-alpine$", r) for r in regels), "nginx:alpine beweegt mee en is ook mét digest niet toegestaan; " "zie Docs/Referenties/Images-pinnen.md") u.check("het entrypoint van de nginx-image is uitgeschakeld", "ENTRYPOINT []" in regels, "anders draait het envsubst-script van die image mee, en dat is " "precies het soort stille invulling dat dit project vreest") # De compose noemt paden in de image; die moeten er zijn. compose = zonder_commentaar(lees(COMPOSE)) u.check("de compose start de agent op het pad waar de Dockerfile hem neerzet", any(r == "- /app/agent.py" for r in compose)) def test_compose_en_recept_lopen_gelijk(u): """Dezelfde image in beide services, met de tag uit build.sh. Twee services die elk hun eigen tag hebben lopen uit elkaar bij de eerste verhoging die iemand op één plek doet. En een tag die niet gelijk is aan VERSION in build.sh betekent een image die nooit gebouwd is. """ compose = zonder_commentaar(lees(COMPOSE)) images = [r.split(":", 1)[1].strip() for r in compose if r.startswith("image:")] u.check("de compose heeft twee images", len(images) == 2, "gevonden: %r" % images) tags = set() for image in images: zonder_digest = image.split("@", 1)[0] tags.add(zonder_digest) u.check("en het is twee keer dezelfde", len(tags) == 1, "gevonden: %r" % sorted(tags)) versie = None for regel in zonder_commentaar(lees(BUILD)): if regel.startswith("VERSION="): versie = regel.split("=", 1)[1].strip().strip('"') u.check("build.sh heeft een VERSION", bool(versie)) if versie and tags: tag = sorted(tags)[0] u.check("de tag in de compose is VERSION uit build.sh", tag.endswith(":" + versie), "compose %r, build.sh %r" % (tag, versie)) # Wat er niet meer gemount mag worden: de programmabestanden. Zit er weer # een mount van een los bestand uit de app-map in, dan is dat een terugval # naar de template-tijd en bereikt een wijziging het apparaat via twee wegen. losse = [r for r in compose if r.startswith("- ${APP_DATA_DIR}/") and "/" not in r[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]] u.check("geen losse bestanden uit de app-map meer gemount", not losse, "gevonden: %r" % losse) u.check("de server krijgt het backend-adres uit de omgeving", compose.count("GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}") == 2 and compose.count("GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}") == 2, "agent én server hebben het nodig; entrypoint.sh vult er het " "stream-blok mee in") u.check("de agent krijgt de versie van het manifest door", "GATE_APP_VERSION: ${APP_VERSION}" in compose, "anders toont de kop van de pagina geen versie") 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. 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. """ regels = zonder_commentaar(lees(ENTRYPOINT)) 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("/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. compose = zonder_commentaar(lees(COMPOSE)) poorten = [r for r in compose 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 entrypoint.sh staat :%s, dus telt de teller nul" % hexpoort) stream = zonder_commentaar(lees(STREAM_CONF)) u.check("en stream.conf luistert op diezelfde poort", any(r == "listen %d ssl;" % poort for r in stream)) 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}/", "") if not host.startswith("data/"): fout.append(host) u.check("elke mount uit de app-map 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 test_pagina_zonder_invulling(u): """De pagina zit in de image en wordt door niemand meer ingevuld. Een ${...} die blijft staan komt letterlijk in de browser terecht. En de twee waarden die umbreld vroeger invulde, de versie en het backend-adres, moeten nu uit status.json komen; anders is de kop leeg en het adres ook. """ tekst = lees(PAGINA) u.check("de pagina bevat geen accolade-variabele", "${" not in tekst) u.check("de kop haalt de versie uit status.json", "s.version" in tekst and 'id="head-version"' in tekst) u.check("het backend-adres komt uit status.json", "backend.address" in tekst) def main(): u = Uitslag() test_pagina_hangt_niet_aan_een_certificaat(u) test_stream_blok_staat_apart(u) test_entrypoint_wacht_niet_op_de_agent(u) test_dockerfile_neemt_alles_mee(u) test_compose_en_recept_lopen_gelijk(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) test_pagina_zonder_invulling(u) return u.rapport() if __name__ == "__main__": sys.exit(main())