Files
UmbrelApps/whatsnext-evolu-relay/agent.py.template
T
HarmenandClaude Opus 5 576acd20dd Het pakket omgebouwd: kale relay, statuspagina, poorten omgedraaid
De app-map is niet langer de relay van Trezor met een Postgres en een
quota-manager ernaast, maar de relay van Evolu met onze eigen allowlist eromheen.
Drie containers werden relay, agent en nginx; het wachtwoord en de database zijn
verdwenen.

De poorten zijn omgedraaid en dat is de kern. Tot 0.0.2 hing de relay achter
app_proxy en moest de inlog van umbrelOS dus uit, waardoor een statuspagina net zo
onbeschermd zou zijn als de relay zelf. Nu hangt de pagina daar met de inlog aan,
en publiceert de relay zijn eigen poort 3852 waar Zoraxy met TLS naartoe wijst.
Dat is hetzelfde patroon als Electrum Gate met 50022. Niet 4000 op de host, want
dat is een veelgebruikte poort en een botsing merk je pas als de app niet start.

De agent beslist niets: hij leest wat het relay-proces heeft opgeschreven en legt
opdrachten in een postbus die de relay zelf leegmaakt. Twee processen die in
dezelfde allowlist schrijven is een wedloop die je een keer per jaar treft en dan
niet kunt reproduceren. Hij weigert ook een tweede opdracht zolang de vorige er
nog ligt, want overschrijven zou er stil een laten verdwijnen.

De pagina volgt het ontwerpsysteem van Electrum Gate, zonder de Google
Fonts-verwijzing daaruit: een app op een Umbrel hoort niet te wachten op een
lettertype van buiten. Alles is met stringoptelling geschreven en zonder enig
dollarteken, want umbreld haalt elke template door envsubst en zou een
JavaScript-template-literal stilzwijgend leegmaken.

De image is door de gebruiker gebouwd en geduwd; de compose is gepind op tag plus
digest. Manifest naar 0.2.0, met een beschrijving en releaseNotes die kloppen met
wat er nu draait in plaats van met het vorige pakket.

Wat hier NIET mee bewezen is, en dat is meer dan gebruikelijk: de agent is alleen
op syntaxis gecontroleerd, de pagina is nooit gerenderd, en of een opdracht van de
pagina daadwerkelijk bij het relay-proces aankomt is niet gemeten. Dat kan alleen
op het apparaat en staat als taak.

Tests: alle vier groen (32, 54, 39 en 60 goed, 0 fout).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:22:32 +02:00

260 lines
9.5 KiB
Plaintext

# ═══════════════════════════════════════════════════════════════════════════════
# 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.
#
# LET OP: umbreld haalt dit bestand bij elke start door envsubst. Er mag dus geen
# dollarteken in staan, ook niet in een regex of een tekst. Een accolade-variabele
# die niet bestaat wordt leeg, en dat sloopt Python-code zonder foutmelding.
# tests/test_appstore_vorm.py controleert dat.
# ═══════════════════════════════════════════════════════════════════════════════
import json
import os
import socket
import threading
import time
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"
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"))
# 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
# 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 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 build_status():
owners = read_owners()
state = owners["state"] or {}
return {
"relay": {
"reachable": probe["reachable"],
"checked": probe["checked"],
"publicPort": PUBLIC_PORT,
},
"owners": {
"problem": owners["problem"],
# 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": state.get("learning"),
"allowed": [
entry
for entry in state.get("owners", [])
if isinstance(entry, dict) and entry.get("allowed") is True
],
"blocked": [
entry
for entry in state.get("owners", [])
if isinstance(entry, dict) and entry.get("allowed") is False
],
"rejected": [
entry for entry in state.get("rejected", []) if isinstance(entry, dict)
],
},
"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"
return {"action": action, "value": value}, 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 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 do_POST(self):
if self.path.rstrip("/") not in ("/api/command", "/command"):
self._send(404, {"error": "onbekend pad"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._send(400, {"error": "lengte ontbreekt"})
return
if length <= 0 or length > MAX_BODY_BYTES:
self._send(400, {"error": "lege of te grote opdracht"})
return
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, ValueError):
self._send(400, {"error": "onleesbare opdracht"})
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()