Eén app store, twee apps
umbrelOS leest per store één repo, dus twee apps in twee repo's kan niet. Deze repo is de store en bevat vanaf nu Electrum Gate en het werk aan Evolu Relay. Opgezet als verse repo op verzoek van de gebruiker: de historie van ElectrumTLS en van EvoluRelay komt niet mee. Dat heeft één gevolg dat verder gaat dan opruimen. In de historie van ElectrumTLS staat het domein van de gebruiker en het certificaatpad, van vóór de opschoning van 19-08. Die komt hier niet in. Zolang die repo op de Git-server blijft staan verandert dat niets, dus het weghalen ervan is het laatste stuk van open punt 3 van het plan Appstore, en geen bijzaak. De store zelf hoefde niet te veranderen: store-id whatsnext, en dus blijft het app-id whatsnext-electrum-gate. Dat hangt aan het store-id en niet aan de URL, dus voor umbrelOS is dit dezelfde app in een andere store. Dat de store op 19-08 naar de maker genoemd werd in plaats van naar deze ene app, betaalt zich hier uit. Wat de documentatie betreft is dit één wortel voor beide apps, en dat was de reden om samen te voegen en niet de prijs ervan: de appstore-spec, het pinnen van images en de werkwijze golden al voor allebei en stonden in twee repo's naast elkaar. De kruisverwijzing die daarvoor nodig was (Referenties/Umbrel-appstore.md in de oude EvoluRelay-repo) is verdwenen; wat daarin stond over de plekken waar de relay een ander geval is, staat nu als ontwerp in het masterplan Umbrelapp §4. Botsende namen kregen een achtervoegsel met de app, en alleen die: Publicatie werd Publicatie-Gate en Publicatie-Relay, CHANGELOG.md werd CHANGELOG-electrum-gate.md. Proefopstelling kreeg 007, tussen de twee bestaande nummers, zodat de bovenkant van de reeks op tier-orde blijft staan. CONTINUE_HERE.md heeft een kolom App, maar de tiers lopen over beide apps heen: er is één volgorde van werken. Electrum Gate gaat naar 0.0.15, want website, repo, support, submission en icon wijzen nu naar UmbrelApps en zonder versieverhoging rolt dat niet uit. De release notes leggen aan de gebruiker uit dat hij de store opnieuw moet toevoegen. Of een geïnstalleerde app een wisseling van store-URL overleeft is nog steeds niet uitgezocht; dat blijkt bij het omzetten. Twee dingen in de plannen van Electrum Gate waren door deze verhuizing niet meer waar en zijn bijgewerkt: de taak "de repo hernoemen" in fase 7 is afgevinkt, en de repo-vorm in PLAN.md §4a toonde nog de store-id electrumtls, die al sinds fase 7 achterhaald was. Tests: 39 goed 0 fout en 54 goed 0 fout, niets overgeslagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user