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:
co-authored by
Claude Fable 5.1
parent
0e824a6354
commit
a694a9cbd1
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user