Gate 0.1.0: eigen image, het command-blok is een script, de app-map is leeg

Plan Eigenimage, fase 1 tot en met 3. De vier templates verhuizen naar
tools/electrum-gate/ zonder extensie; daarnaast Dockerfile (nginx:1.30-alpine
plus python3), entrypoint.sh (het command-blok van de compose, zonder $$) en
build.sh naar het voorbeeld van Evolu Relay. Een image voor beide containers,
gebouwd op de Umbrel; open punt 2 en 3 daarmee beslist.

Inhoudelijk anders dan alleen verplaatst: het log_format staat in stream.conf
zelf, het backend-adres komt via twee plaatshouders zonder dollarteken uit de
omgeving (ook in de server-service), en de pagina haalt versie en adres uit
status.json via GATE_APP_VERSION.

Tests mee verhuisd en uitgebreid: entrypoint.sh en Dockerfile in plaats van het
command-blok, en de tag in de compose gelijk aan VERSION in build.sh voor elke
eigen image. Mutatie-getest met drie ingrepen.

Nog niet gebouwd: er is hier geen Docker. De tag staat ongepind tot de eerste
push; dat is fase 4 en die is van de gebruiker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-07 20:20:42 +02:00
co-authored by Claude Fable 5.1
parent 7b3804df89
commit 973b24a23f
25 changed files with 981 additions and 410 deletions
+211 -62
View File
@@ -9,18 +9,25 @@ 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.
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 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
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.
@@ -31,15 +38,20 @@ 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(APP, "nginx.conf.template")
STREAM_CONF = os.path.join(APP, "stream.conf.template")
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")
PAGINA = os.path.join(APP, "index.html.template")
class Uitslag:
@@ -67,7 +79,7 @@ def lees(pad):
def zonder_commentaar(tekst):
"""De regels die nginx daadwerkelijk uitvoert.
"""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.
@@ -104,64 +116,71 @@ def test_pagina_hangt_niet_aan_een_certificaat(u):
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")
"""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
tekst = lees(STREAM_CONF)
regels = zonder_commentaar(tekst)
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 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))
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))
# 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])
# 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_compose_wacht_niet_op_de_agent(u):
"""Het command-blok start nginx altijd, en zet TLS erbij als het kan."""
tekst = lees(COMPOSE)
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 /var/lib/gate/cert.conf" in r and "while" in r
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("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 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("cp /etc/nginx/stream.conf /var/lib/gate/tls/" in r
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("rm -f /var/lib/gate/tls/stream.conf" in r for r in regels),
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",
@@ -169,6 +188,119 @@ def test_compose_wacht_niet_op_de_agent(u):
"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.
@@ -208,13 +340,12 @@ def test_uploadpad(u):
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.
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)
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),
@@ -222,11 +353,12 @@ def test_verbindingsteller(u):
"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))
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.
poorten = [r for r in regels if r.startswith("GATE_TLS_PORT:")]
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:
@@ -238,9 +370,13 @@ def test_verbindingsteller(u):
# 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"
"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/`.
@@ -262,14 +398,9 @@ def test_data_onder_data(u):
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/",
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/",
@@ -382,17 +513,35 @@ def test_tagline_is_overal_dezelfde(u):
"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_compose_wacht_niet_op_de_agent(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()