Relay 0.6.0: alles in een image, de app-map is leeg

Plan Eigenimage, fase 5, op keuze van de gebruiker: een image met de relay
erbij en geen tweede recept. agent.py, nginx.conf en index.html verhuizen
naar tools/evolu-relay/ naast src/; de Dockerfile blijft op node:24-slim en
haalt nginx en python3 uit apt. Drie containers uit een image: de relay als
node via de compose, de agent en nginx als root.

Anders dan alleen verplaatst: user www-data in nginx.conf (Debian heeft geen
gebruiker nginx), geen USER meer in de image, de versie in de kop via
api/status met RELAY_APP_VERSION. VERSION 0.6.0, manifest 0.6.0, drie keer
dezelfde tag in de compose, ongepind tot de eerste push.

Tests mee verhuisd; de vormtest toetst de drie tags tegen VERSION. Niet
gebouwd: er is hier geen Docker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-08 16:36:30 +02:00
co-authored by Claude Fable 5.1
parent 0e824a6354
commit a694a9cbd1
18 changed files with 327 additions and 177 deletions
+39 -7
View File
@@ -1,6 +1,14 @@
# ═══════════════════════════════════════════════════════════════════════════════
# De image voor de app whatsnext-evolu-relay.
#
# Eén image voor drie containers. De compose start hem drie keer: als `relay`
# met het standaardcommando (`node src/index.js`, als gebruiker node), als
# `agent` met `python3 /app/agent.py` en als `server` met nginx. Tot 0.5.6 zat
# alleen de relay in deze image en draaiden de agent en de pagina als *.template
# op python:3-alpine en nginx:alpine; sinds 0.6.0 zit alles hier. Beslist door de
# gebruiker op 08-09-2026, na dezelfde stap bij Electrum Gate: één image scheelt
# een tweede recept en een tweede pin. Zie het plan Eigenimage, PLAN.md §8.
#
# Dit bouwt niet de relay van Evolu na: het installeert `@evolu/nodejs` uit npm
# en start ons eigen `src/index.js`, dat alleen de twee terugroepfuncties voor de
# toegangscontrole toevoegt.
@@ -8,28 +16,52 @@
# Waarom `slim` en niet `alpine`: `better-sqlite3` zit onder `@evolu/nodejs` en
# heeft kant-en-klare binaries voor glibc, niet voor musl. Op alpine zou hij bij
# elke bouw opnieuw gecompileerd moeten worden, met een bouwketen erbij in de
# image.
# image. Dat is ook de reden dat nginx en python3 hier uit apt komen en niet
# andersom node in een nginx-image gezet is.
#
# De bouwcontext is de REPO-ROOT, niet deze map: icon.png blijft in de app-map
# staan (het manifest wijst ernaar voor de tegel in de winkel) en wordt van daar
# gekopieerd, zodat er niet twee exemplaren zijn. build.sh geeft die context mee.
# ═══════════════════════════════════════════════════════════════════════════════
FROM node:24-slim
# nginx en python3 uit Debian. Geen aanbevolen pakketten: die halen bij nginx
# onder meer een set modules binnen die de pagina niet gebruikt. De agent heeft
# alleen de standaardbibliotheek nodig.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Eerst alleen het manifest, zodat een wijziging in `src/` de installatielaag niet
# ongeldig maakt.
COPY package.json ./
COPY tools/evolu-relay/package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY tools/evolu-relay/src ./src
COPY tools/evolu-relay/agent.py ./agent.py
# De relay maakt zelf `data/` aan bij de start, maar dan als de gebruiker die op
# dat moment draait. Vooraf aanmaken met de juiste eigenaar voorkomt dat een
# gemounte map van de host als root wordt aangeraakt.
RUN mkdir -p /app/data && chown -R node:node /app
# gemounte map van de host als root wordt aangeraakt. Alleen `data/` en `src/` zijn
# van node; agent.py en de rest blijven van root, want die worden alleen gelezen.
RUN mkdir -p /app/data && chown -R node:node /app/data /app/src
USER node
# De pagina. Debian zet nginx' eigen voorbeeldpagina onder /var/www/html; wij
# gebruiken het pad dat de nginx-image ook gebruikt, zodat nginx.conf gelijk kan
# blijven aan die van Electrum Gate.
COPY tools/evolu-relay/nginx.conf /etc/nginx/nginx.conf
COPY tools/evolu-relay/index.html /usr/share/nginx/html/index.html
COPY whatsnext-evolu-relay/icon.png /usr/share/nginx/html/icon.png
# Geen USER hier, en dat is met opzet: nginx moet als root beginnen om poort 80 te
# nemen, en de agent schrijft in de gedeelde map. De relay hoort wél als node te
# draaien, zoals hij tot 0.5.6 deed, en dat regelt de compose met `user: node`.
ENV NODE_ENV=production
EXPOSE 4000
ENV PYTHONUNBUFFERED=1
EXPOSE 4000 80
CMD ["node", "src/index.js"]
+527
View File
@@ -0,0 +1,527 @@
# ═══════════════════════════════════════════════════════════════════════════════
# De agent van Evolu Relay.
#
# Hij bedient de statuspagina en doet verder niets: lezen wat het relay-proces
# heeft opgeschreven, en opdrachten van de pagina in de postbus leggen.
#
# Waarom de agent niet zelf beslist wie er binnen mag: dat beleid hoort bij het
# proces dat de verbindingen aanneemt, en dat is de relay. Twee processen die in
# dezelfde allowlist schrijven is een wedloop die je een keer per jaar treft en
# dan niet kunt reproduceren. De agent schrijft daarom uitsluitend command.json,
# en het relay-proces past hem toe en ruimt hem op.
#
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als /app/agent.py.
# Tot 0.5.6 was het een *.template in de app-map en haalde umbreld het bij elke
# start door envsubst, en daarom staat er geen dollarteken in en komen alle
# instellingen uit de omgeving. Dat laatste is zo gebleven: de compose zet ze in
# de omgeving neer, en dat is ook de nette weg. tests/test_relay_agent.py houdt
# het dollarteken buiten de deur, voor het geval dit ooit weer een template wordt.
# ═══════════════════════════════════════════════════════════════════════════════
import json
import os
import socket
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
STATE_DIR = Path(os.environ.get("RELAY_STATE_DIR", "/var/lib/relay"))
OWNERS_FILE = STATE_DIR / "owners.json"
COMMAND_FILE = STATE_DIR / "command.json"
DATABASE_FILE = STATE_DIR / "evolu-relay.db"
# De labels die de gebruiker aan een eigenaar-id hangt. Een eigen bestand, en dat
# is de kern van deze keuze: owners.json is van het relay-proces en labels.json is
# van de agent, dus er is per bestand precies één schrijver. Dat is dezelfde
# afspraak die de postbus hierboven oplevert, en de reden staat bovenaan dit
# bestand.
#
# Wat het bovendien oplevert: een label is meteen opgeslagen en niet pas als de
# relay de postbus leegmaakt, en labelen blijft werken als de relay omgevallen is.
# Dat mag, want de relay hoeft dit niet te weten: een label zegt niets over wie er
# binnen mag.
LABELS_FILE = STATE_DIR / "labels.json"
RELAY_HOST = os.environ.get("RELAY_HOST", "")
RELAY_PORT = int(os.environ.get("RELAY_PORT", "4000"))
PUBLIC_PORT = int(os.environ.get("RELAY_PUBLIC_PORT", "3852"))
API_PORT = int(os.environ.get("RELAY_API_PORT", "8000"))
PROBE_INTERVAL = int(os.environ.get("RELAY_PROBE_INTERVAL", "15"))
# De versie uit het manifest, voor de kop van de pagina. Tot 0.5.6 vulde umbreld
# die rechtstreeks in de pagina in; nu de pagina in de image zit, loopt het via de
# status. Leeg betekent dat de compose hem niet doorgeeft, en dan toont de pagina
# geen versie in plaats van een verzonnen.
APP_VERSION = os.environ.get("RELAY_APP_VERSION", "")
# Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
# hier te stranden en niet daar.
ALLOWED_ACTIONS = ("set-learning", "block", "allow", "forget")
MAX_BODY_BYTES = 4096
MAX_OWNER_ID_LENGTH = 256
# Een label is een herkenpunt en geen aantekenveld: het staat op de pagina naast
# een id en moet daar op één regel passen.
MAX_LABEL_LENGTH = 48
# Een bovengrens op het aantal labels. De pagina zit achter de inlog van umbrelOS,
# dus dit is geen verdediging tegen een aanvaller maar tegen een lus die per
# ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft.
MAX_LABELS = 200
# Hoelang de pagina de deur voor nieuwe eigenaars openzet. Dezelfde waarde staat
# in de pagina; die stuurt hem mee en het relay-proces begrenst hem nog een keer.
# Hier staat hij omdat de agent hem moet toestaan, niet omdat hij hem kiest.
MAX_LEARNING_SECONDS = 3600
# Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig:
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
# labelen is lezen-wijzigen-schrijven. Dit slot maakt daar één handeling van.
labels_lock = threading.Lock()
# Door de achtergrondlus bijgewerkt, door de webserver gelezen. Een dict wordt in
# zijn geheel vervangen en nooit ter plekke aangepast, zodat een lezer altijd een
# samenhangend beeld heeft zonder slot.
probe = {"reachable": None, "checked": None}
def relay_reachable():
"""Kan de relay een TCP-verbinding aannemen.
Bewust niet meer dan dat. De relay is een WebSocket-server en antwoordt niet
op een gewoon verzoek; een handdruk nabouwen om de pagina groen te krijgen is
meer code dan het waard is. Wat dit wel uitsluit is de meest voorkomende
storing: het proces is omgevallen.
"""
if not RELAY_HOST:
return None
try:
with socket.create_connection((RELAY_HOST, RELAY_PORT), timeout=3):
return True
except OSError:
return False
def probe_loop():
global probe
while True:
# In zijn geheel vervangen en niet ter plekke aanpassen: een lezer ziet
# dan altijd een samenhangend beeld, zonder dat er een slot nodig is.
probe = {
"reachable": relay_reachable(),
"checked": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
time.sleep(PROBE_INTERVAL)
def read_owners():
"""De allowlist zoals het relay-proces hem heeft achtergelaten."""
try:
with OWNERS_FILE.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
# Nog nooit geschreven. Dat is de normale toestand vlak na een
# installatie: het relay-proces schrijft pas bij de eerste wijziging.
return {"state": None, "problem": "nog-niet-aangemaakt"}
except (OSError, ValueError):
return {"state": None, "problem": "onleesbaar"}
if not isinstance(data, dict):
return {"state": None, "problem": "onleesbaar"}
return {"state": data, "problem": None}
def read_labels():
"""De labels, of een lege verzameling.
Bewust vergevingsgezind, en dat is het omgekeerde van hoe owners.json gelezen
wordt. Daar hangt aan een half begrepen bestand de vraag wie er binnen mag, en
dan is weigeren het antwoord. Hier gaat het om een naam naast een id: is het
onleesbaar, dan is het ergste gevolg dat je de rauwe ids ziet.
"""
try:
with LABELS_FILE.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
schoon = {}
for owner_id, label in data.items():
if not isinstance(owner_id, str) or not isinstance(label, str):
continue
if not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
continue
label = label.strip()
if label:
schoon[owner_id] = label[:MAX_LABEL_LENGTH]
return schoon
def write_labels(labels):
"""Schrijft de labels. Eerst een tijdelijk bestand en dan hernoemen.
Hernoemen binnen dezelfde map is atomair, dus een onderbroken schrijfactie
laat geen half bestand achter. Dezelfde constructie als de postbus.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
temporary = LABELS_FILE.with_suffix(".json.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(labels, handle, indent=2, sort_keys=True)
handle.write("\n")
temporary.replace(LABELS_FILE)
def parse_moment(value):
"""Een ISO-tijdstip uit owners.json als datetime, of None.
Het relay-proces schrijft `new Date().toISOString()`, dus met milliseconden en
met een Z erachter. `fromisoformat` neemt die Z sinds Python 3.11; de image is
python:3-alpine en dus nieuwer. Faalt het alsnog, dan is None het antwoord en
beslist de aanroeper.
"""
if not isinstance(value, str):
return None
try:
when = datetime.fromisoformat(value)
except ValueError:
return None
if when.tzinfo is None:
return when.replace(tzinfo=timezone.utc)
return when
def learning_facts(state):
"""De leerstand zoals de pagina hem hoort te zien.
Het veld in het bestand is niet het hele antwoord: staat er een tijdstip in dat
verstreken is, dan is de deur dicht, ook al staat `learning` nog op true. Het
relay-proces ruimt dat op in zijn eigen lus, en tussen het aflopen en die ronde
zit een seconde of twee. De pagina hoort daar niet "open" te tonen.
De resterende tijd wordt hier uitgerekend en niet in de browser. Dat is met
opzet: dan telt de klok van de Umbrel en niet die van de bezoeker, en die twee
lopen niet per definitie gelijk.
"""
learning = state.get("learning")
until = state.get("learningUntil")
if learning is not True:
return {"learning": learning, "learningUntil": None, "learningSecondsLeft": None}
when = parse_moment(until)
if when is None:
# Geen tijdslot: open tot de gebruiker hem zelf sluit. Dat is de
# begintoestand van een verse installatie.
return {"learning": True, "learningUntil": None, "learningSecondsLeft": None}
resterend = (when - datetime.now(timezone.utc)).total_seconds()
if resterend <= 0:
return {"learning": False, "learningUntil": None, "learningSecondsLeft": 0}
return {
"learning": True,
"learningUntil": until,
"learningSecondsLeft": int(resterend),
}
def database_facts():
try:
stat = DATABASE_FILE.stat()
except OSError:
return {"bytes": None, "modified": None}
return {
"bytes": stat.st_size,
"modified": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
}
def met_label(entries, labels):
"""Hangt het label van de gebruiker aan elke regel.
Een kopie en niet ter plekke: wat hier binnenkomt komt uit het bestand van het
relay-proces, en daar horen wij niets aan toe te voegen.
"""
resultaat = []
for entry in entries:
regel = dict(entry)
regel["label"] = labels.get(entry.get("id"))
resultaat.append(regel)
return resultaat
def build_status():
owners = read_owners()
state = owners["state"] or {}
labels = read_labels()
# Ontbreekt de staat, dan is 'learning' onbekend en niet 'false'. De pagina
# hoort dat verschil te tonen: onbekend is een reden om te kijken, uit is een
# keuze. learning_facts() geeft None door zoals het binnenkwam.
learning = learning_facts(state)
return {
"version": APP_VERSION or None,
"relay": {
"reachable": probe["reachable"],
"checked": probe["checked"],
"publicPort": PUBLIC_PORT,
},
"owners": {
"problem": owners["problem"],
"learning": learning["learning"],
"learningUntil": learning["learningUntil"],
"learningSecondsLeft": learning["learningSecondsLeft"],
"allowed": met_label(
[
entry
for entry in state.get("owners", [])
if isinstance(entry, dict) and entry.get("allowed") is True
],
labels,
),
"blocked": met_label(
[
entry
for entry in state.get("owners", [])
if isinstance(entry, dict) and entry.get("allowed") is False
],
labels,
),
"rejected": met_label(
[entry for entry in state.get("rejected", []) if isinstance(entry, dict)],
labels,
),
},
"database": database_facts(),
# Ligt er nog een opdracht, dan heeft het relay-proces hem nog niet
# opgepakt. De pagina kan dat tonen in plaats van te doen alsof er niets
# gebeurd is.
"pendingCommand": COMMAND_FILE.exists(),
}
def valid_command(payload):
"""Geeft de opdracht terug, of een foutmelding.
Streng aan deze kant, want dit is de enige plek waar iets van buiten in de
postbus belandt.
"""
if not isinstance(payload, dict):
return None, "geen object"
action = payload.get("action")
if action not in ALLOWED_ACTIONS:
return None, "onbekende actie"
if action == "set-learning":
value = payload.get("value")
if not isinstance(value, bool):
return None, "waarde moet true of false zijn"
seconds = payload.get("seconds")
if seconds is None:
# Zonder tijdslot: open tot de gebruiker hem zelf sluit. Dat pad blijft
# bestaan voor een verse installatie, waar een venster van twee minuten
# zou aflopen terwijl je nog aan het koppelen bent.
return {"action": action, "value": value}, None
# isinstance(True, int) is in Python waar, dus een boolean zou hier als
# aantal seconden doorglippen. Vandaar de uitsluiting.
if isinstance(seconds, bool) or not isinstance(seconds, int):
return None, "seconds moet een heel getal zijn"
if seconds <= 0 or seconds > MAX_LEARNING_SECONDS:
return None, "seconds valt buiten het toegestane bereik"
if value is not True:
return None, "seconds hoort alleen bij openzetten"
return {"action": action, "value": value, "seconds": seconds}, None
owner_id = payload.get("ownerId")
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
return None, "ontbrekende of te lange ownerId"
return {"action": action, "ownerId": owner_id}, None
def valid_label(payload):
"""Geeft (ownerId, label) terug, of een foutmelding.
Een leeg label is geen fout maar de manier om er een weg te halen: dan hoeft er
geen tweede opdracht te bestaan voor iets dat de gebruiker als hetzelfde veld
ziet.
"""
if not isinstance(payload, dict):
return None, None, "geen object"
owner_id = payload.get("ownerId")
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
return None, None, "ontbrekende of te lange ownerId"
label = payload.get("label")
if label is None:
label = ""
if not isinstance(label, str):
return None, None, "label moet tekst zijn"
# Regeleindes eruit: dit is één regel naast een id, en een label met een
# nieuwe regel erin zou de lijst uit elkaar trekken.
label = " ".join(label.split()).strip()
if len(label) > MAX_LABEL_LENGTH:
return None, None, "label is te lang"
return owner_id, label, None
def apply_label(owner_id, label):
"""Zet of haalt een label weg. Geeft een foutmelding terug, of None.
Onder het slot, want dit is lezen-wijzigen-schrijven en de agent bedient
meerdere verzoeken tegelijk.
"""
with labels_lock:
labels = read_labels()
if label:
if owner_id not in labels and len(labels) >= MAX_LABELS:
return "er zijn al te veel labels"
labels[owner_id] = label
else:
if owner_id not in labels:
# Niets te doen, en dat is geen fout: de pagina stuurt een leeg
# label als je het veld leegmaakt, ook als er nog niets stond.
return None
del labels[owner_id]
try:
write_labels(labels)
except OSError as error:
return "label kon niet worden weggeschreven: " + str(error)
return None
def write_command(command):
"""Legt de opdracht in de postbus.
Eerst een tijdelijk bestand en dan hernoemen: het relay-proces kijkt op zijn
eigen moment en mag geen half bestand aantreffen.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
temporary = COMMAND_FILE.with_suffix(".json.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(command, handle)
handle.write("\n")
temporary.replace(COMMAND_FILE)
class Handler(BaseHTTPRequestHandler):
# De standaardregel van BaseHTTPRequestHandler noemt de naam van de server en
# de Python-versie. Dat hoeft niemand te weten.
server_version = "evolu-relay-agent"
sys_version = ""
def log_message(self, format, *args):
# Geen toegangslog. Elke regel zou het adres van de bezoeker bevatten en
# de pagina zit achter de inlog van umbrelOS; er valt niets te zien wat
# het bewaren waard is.
return
def _send(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path.rstrip("/") in ("/api/status", "/status"):
self._send(200, build_status())
return
self._send(404, {"error": "onbekend pad"})
def _read_payload(self):
"""Het verzoek als object, of None als er al een fout verstuurd is."""
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._send(400, {"error": "lengte ontbreekt"})
return None
if length <= 0 or length > MAX_BODY_BYTES:
self._send(400, {"error": "lege of te grote opdracht"})
return None
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, ValueError):
self._send(400, {"error": "onleesbare opdracht"})
return None
def do_POST(self):
pad = self.path.rstrip("/")
# Een label gaat NIET via de postbus, en dat is de enige uitzondering op
# die regel. De reden dat opdrachten er wel door gaan, is dat het
# relay-proces beslist wie er binnen mag en dat twee schrijvers in die
# allowlist een wedloop zou zijn. Een label zegt niets over toegang, staat
# in een eigen bestand met de agent als enige schrijver, en is meteen
# opgeslagen in plaats van na de volgende ronde van de relay.
if pad in ("/api/label", "/label"):
payload = self._read_payload()
if payload is None:
return
owner_id, label, problem = valid_label(payload)
if problem is not None:
self._send(400, {"error": problem})
return
problem = apply_label(owner_id, label)
if problem is not None:
self._send(500, {"error": problem})
return
self._send(200, {"ownerId": owner_id, "label": label})
return
if pad not in ("/api/command", "/command"):
self._send(404, {"error": "onbekend pad"})
return
payload = self._read_payload()
if payload is None:
return
command, problem = valid_command(payload)
if command is None:
self._send(400, {"error": problem})
return
# Eén opdracht tegelijk. Ligt er nog een, dan zou schrijven hem stil
# overschrijven en verdwijnt de vorige zonder dat iemand het merkt.
if COMMAND_FILE.exists():
self._send(409, {"error": "vorige opdracht is nog niet verwerkt"})
return
try:
write_command(command)
except OSError as error:
self._send(500, {"error": "opdracht kon niet worden weggeschreven: " + str(error)})
return
self._send(202, {"accepted": command})
def main():
threading.Thread(target=probe_loop, daemon=True).start()
ThreadingHTTPServer(("0.0.0.0", API_PORT), Handler).serve_forever()
if __name__ == "__main__":
main()
+64 -27
View File
@@ -2,9 +2,10 @@
# ═══════════════════════════════════════════════════════════════════════════════
# Bouwt de image voor de app whatsnext-evolu-relay.
#
# Wat er gebouwd wordt is ONS programma: `src/index.js` roept `createRelay` uit
# `@evolu/nodejs` aan met onze eigen toegangscontrole erin. De relay zelf komt
# gewoon uit npm en wordt niet nagebouwd of aangepast.
# Eén image voor de drie containers van de app: de relay (ons `src/index.js` om
# `@evolu/nodejs` heen), de agent (`agent.py`) en nginx met de pagina. De relay
# zelf komt uit npm en wordt niet nagebouwd of aangepast. Tot 0.5.6 zat alleen de
# relay in deze image; sinds 0.6.0 alles, zie de Dockerfile.
#
# Dit verving op 28-08-2026 het vorige recept, dat de repo van Trezor kloonde en
# hun Dockerfile bouwde. Dat pakket had een Postgres en een quota-manager nodig en
@@ -17,8 +18,13 @@
# bovendien minuten laten hangen. Het recept hoort dus in de repo en het resultaat
# in een register; de app verwijst alleen naar de tag.
#
# Draaien: sh build.sh
# Vereist: docker op de machine waar je bouwt. Git is niet meer nodig.
# Draaien: sh tools/evolu-relay/build.sh
# Vereist: docker op de machine waar je bouwt. Op de Umbrel is dat met sudo.
#
# Multi-arch: PLATFORMS="linux/amd64,linux/arm64" sh tools/evolu-relay/build.sh
# Vraagt buildx met QEMU en duwt meteen. Nog nooit geprobeerd; zie het masterplan
# Publicatie-Relay. Zonder PLATFORMS bouwt dit één architectuur, die van de
# machine waarop je staat.
# ═══════════════════════════════════════════════════════════════════════════════
set -eu
@@ -28,15 +34,15 @@ set -eu
# zijn dáár de pin. Hier staat alleen het etiket op de uitkomst.
#
# Dit is het etiket op de IMAGE. Verhoog het als de image verandert, dus als er
# iets in src/, package.json of de Dockerfile wijzigt, en zet dan dezelfde waarde
# achter `image:` in de compose.
# iets in deze map of in icon.png wijzigt, en zet dan dezelfde waarde achter
# `image:` in de compose (drie keer: relay, agent en server delen hem).
#
# `version` in umbrel-app.yml is een ander nummer en mag hierop vooruitlopen: dat
# moet bij élke wijziging aan de app omhoog, ook als alleen een template
# verandert, want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een
# dag gekost. Ze zijn dus gelijk zolang alleen de image wijzigt, en lopen uiteen
# zodra er een reparatie in de app-map zit.
VERSION="0.5.0"
# moet bij élke wijziging aan de app omhoog, ook als alleen de compose verandert,
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
# Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je
# VERSION, dan ook het manifest.
VERSION="0.6.0"
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
@@ -45,12 +51,22 @@ VERSION="0.5.0"
IMAGE="sc.kamenier-hamer.nl/sysop/evolu-relay:${VERSION}"
RECEPT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# De bouwcontext is de repo-root, want icon.png komt uit de app-map. Zie de kop
# van de Dockerfile.
REPO="$(CDPATH= cd -- "${RECEPT}/../.." && pwd)"
# ── Controles vooraf ──────────────────────────────────────────────────────────
# Beter hier hard falen dan een image bouwen die iets anders bevat dan je denkt.
if [ ! -f "${RECEPT}/package.json" ] || [ ! -f "${RECEPT}/src/index.js" ]; then
echo "FOUT: package.json of src/index.js ontbreekt in ${RECEPT}" >&2
for bestand in Dockerfile package.json src/index.js agent.py nginx.conf index.html; do
if [ ! -f "${RECEPT}/${bestand}" ]; then
echo "FOUT: ${bestand} ontbreekt in ${RECEPT}" >&2
exit 1
fi
done
if [ ! -f "${REPO}/whatsnext-evolu-relay/icon.png" ]; then
echo "FOUT: whatsnext-evolu-relay/icon.png ontbreekt in ${REPO}" >&2
exit 1
fi
@@ -63,24 +79,45 @@ if [ ! -f "${RECEPT}/package-lock.json" ]; then
echo
fi
# Een accolade-variabele hoort hier nergens meer in: deze bestanden gaan niet
# meer door envsubst, dus wat er staat is wat er draait. Staat er tóch een, dan
# is dat een restant van de template-tijd en zou hij letterlijk in de pagina of
# de configuratie terechtkomen.
if grep -l '\${' "${RECEPT}/nginx.conf" "${RECEPT}/index.html" "${RECEPT}/agent.py" 2>/dev/null; then
echo "FOUT: de bestanden hierboven bevatten nog een \${...}; die wordt niet meer ingevuld." >&2
exit 1
fi
# ── Bouwen ────────────────────────────────────────────────────────────────────
echo "Bouwen als ${IMAGE}"
docker build --tag "$IMAGE" "$RECEPT"
PLATFORMS="${PLATFORMS:-}"
if [ -n "$PLATFORMS" ]; then
echo "Bouwen als ${IMAGE} voor ${PLATFORMS}, en meteen duwen"
docker buildx build --platform "$PLATFORMS" --tag "$IMAGE" --push \
--file "${RECEPT}/Dockerfile" "$REPO"
echo
echo "Geduwd. De index-digest:"
docker buildx imagetools inspect "$IMAGE" | head -3
else
echo "Bouwen als ${IMAGE}"
docker build --tag "$IMAGE" --file "${RECEPT}/Dockerfile" "$REPO"
echo
echo "Klaar:"
docker image inspect --format '{{.RepoTags}} {{.Id}}' "$IMAGE"
echo
echo "Bouwen is niet genoeg: de app verwijst naar het register, want umbreld kan"
echo "niet bij een image die alleen lokaal staat. Nog te doen:"
echo
echo " docker login sc.kamenier-hamer.nl"
echo " docker push ${IMAGE}"
fi
echo
echo "Klaar:"
docker image inspect --format '{{.RepoTags}} {{.Id}}' "$IMAGE"
echo
echo "Bouwen is niet genoeg: de app verwijst naar het register, want umbreld kan"
echo "niet bij een image die alleen lokaal staat. Nog te doen:"
echo
echo " docker login sc.kamenier-hamer.nl"
echo " docker push ${IMAGE}"
echo
echo "Zet daarna de digest uit de push-uitvoer achter de tag in"
echo "whatsnext-evolu-relay/docker-compose.yml, en zet \`version\` in het manifest"
echo "op ${VERSION}. Zonder die verhoging rolt umbrelOS het niet uit."
echo "whatsnext-evolu-relay/docker-compose.yml (drie keer: relay, agent en server),"
echo "en zet \`version\` in het manifest op ${VERSION}. Zonder die verhoging rolt"
echo "umbrelOS het niet uit."
echo
echo "Controleer dat het anoniem te halen is, want umbreld krijgt geen"
echo "inloggegevens mee. Log uit in dezelfde context waarin je inlogde, anders"
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
# nginx-configuratie voor Evolu Relay.
#
# Dit bestand serveert alleen de statuspagina. De relay zelf zit er niet achter:
# die publiceert zijn eigen poort in docker-compose.yml, waar Zoraxy met TLS
# naartoe wijst. Dat is de omkering ten opzichte van het pakket van 25-08-2026,
# waar de relay achter de app-proxy stond en de inlog dus uit moest; zie het plan
# Umbrelapp, PLAN.md §4h.
#
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als
# /etc/nginx/nginx.conf. Tot 0.5.6 was het een *.template in de app-map die
# umbreld bij elke start door envsubst haalde, en daarom mocht er toen geen
# nginx-variabele met een dollarteken in staan. Die beperking is weg. Dat de
# access_log nog steeds uit staat is een keuze en geen restant: de pagina hangt
# achter de app_proxy en wat die opvraagt is niet interessant.
# www-data en niet nginx: deze nginx komt uit Debian (de image is node:24-slim
# met nginx uit apt), en Debian heeft geen gebruiker nginx. Bij Electrum Gate
# staat hier wél nginx, want die image is de officiële nginx-image.
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log off;
sendfile on;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# De API van de agent: de status lezen en een opdracht in de postbus
# leggen. Dit is het enige pad waarlangs iets van buiten de instellingen
# van de app raakt.
#
# Waarom dat hier te verantwoorden is: dit pad hangt achter de app_proxy
# van umbrelOS, die er zijn eigen inlog voor zet. De relay staat op een
# andere poort en is hierlangs niet te bereiken. En het ergste wat een
# geslaagde aanroep doet is de leerstand omzetten of een eigenaar
# blokkeren, en dat is precies waar de pagina voor is.
#
# Geen proxy_set_header: de agent heeft ze niet nodig.
#
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
# eerst, en op het apparaat gaf het een 404 bij ongeveer elke tweede
# aanroep (28-08-2026): Electrum Gate heeft óók een service 'agent' op
# poort 8000, beide apps zitten op hetzelfde Docker-netwerk, en de korte
# naam kan dus naar de verkeerde container wijzen. De vorm
# <app-id>_<service>_1 is uniek per app. Dezelfde les stond al in de
# compose van Electrum Gate, over de servicenaam 'server'.
location /api/ {
proxy_pass http://whatsnext-evolu-relay_agent_1:8000;
# Een opdracht is een handvol bytes. De agent kapt zelf ook af, op
# 4k; dit is de eerste zeef en niet de enige.
client_max_body_size 4k;
}
}
}