UmbrelApps: community app store whatsnext
Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
85d85a1295
63 files changed
+13987
No files matched your search
@@ -0,0 +1,67 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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.
|
||||
#
|
||||
# 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. 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 tools/evolu-relay/package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
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. 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
|
||||
|
||||
# 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
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 4000 80
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -0,0 +1,438 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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 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 = ("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
|
||||
|
||||
# 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 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()
|
||||
|
||||
return {
|
||||
"version": APP_VERSION or None,
|
||||
"relay": {
|
||||
"reachable": probe["reachable"],
|
||||
"checked": probe["checked"],
|
||||
"publicPort": PUBLIC_PORT,
|
||||
},
|
||||
"owners": {
|
||||
"problem": owners["problem"],
|
||||
"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"
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/bin/sh
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Bouwt de image voor de app whatsnext-evolu-relay.
|
||||
#
|
||||
# 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
|
||||
# kon als zelf-gehoste relay in de kern niet werken; zie het plan Umbrelapp,
|
||||
# OPEN.md punt 6.
|
||||
#
|
||||
# Waarom dit NIET in de app-map staat: umbreld kopieert de hele app-map naar het
|
||||
# apparaat, en bij een update wordt alleen een whitelist ververst waar een
|
||||
# Dockerfile niet in zit. Bouwen tijdens het starten van de app zou de installatie
|
||||
# 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 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
|
||||
|
||||
# ── De pin ────────────────────────────────────────────────────────────────────
|
||||
# De versies van `@evolu/common` en `@evolu/nodejs` staan exact in package.json en
|
||||
# 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 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 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.7.1"
|
||||
|
||||
# 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
|
||||
# hem onbereikbaar en de installatie faalt met "pull access denied". Dat is op
|
||||
# 25-08-2026 op het apparaat vastgesteld. Zie Umbrel-appstore-spec.md.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
if [ ! -f "${RECEPT}/package-lock.json" ]; then
|
||||
echo "LET OP: er is geen package-lock.json, dus npm kiest zelf de onderliggende"
|
||||
echo " afhankelijkheden. De twee pakketten van Evolu staan exact vast in"
|
||||
echo " package.json, maar wat daaronder hangt kan meebewegen. Draai eenmalig"
|
||||
echo " 'npm install' in deze map, commit het lock-bestand, en zet dan in de"
|
||||
echo " Dockerfile 'npm install' om naar 'npm ci'."
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 "Zet daarna de digest uit de push-uitvoer achter de tag in"
|
||||
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"
|
||||
echo "meet je je eigen sessie:"
|
||||
echo
|
||||
echo " docker logout sc.kamenier-hamer.nl && docker pull ${IMAGE}"
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "whatsnext-evolu-relay",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "De relay van Evolu met een eigen eigenaars-allowlist eromheen.",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@evolu/common": "8.7.0",
|
||||
"@evolu/nodejs": "3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// De relay van Evolu, met onze eigen toegangscontrole eromheen.
|
||||
//
|
||||
// Wat dit programma NIET is: een eigen relay. `createRelay` uit `@evolu/nodejs`
|
||||
// doet al het werk; het enige dat wij toevoegen zijn de twee terugroepfuncties
|
||||
// die het bedoelde uitbreidpunt vormen. Trezor doet in `createEvoluRelay.ts`
|
||||
// precies hetzelfde, alleen kijken hun functies in een Postgres.
|
||||
//
|
||||
// De opstartvolgorde en de `runMain`-aanroep zijn overgenomen uit
|
||||
// `apps/relay/src/index.ts` van Evolu zelf. Wijk daar niet van af zonder reden:
|
||||
// dat bestand is de referentie voor hoe deze relay hoort te starten.
|
||||
//
|
||||
// Zie het plan Umbrelapp, PLAN.md §4g, en Referenties/Upstream-evolu-relay.md §8.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { createConsole, createConsoleFormatter } from '@evolu/common';
|
||||
import { installPolyfills } from '@evolu/common/polyfills';
|
||||
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import { applyCommand, decideOwner } from './policy.js';
|
||||
import { readState, takeCommand, writeState } from './store.js';
|
||||
|
||||
installPolyfills();
|
||||
|
||||
// Zoals bovenstrooms: de database komt in een map `data` naast het programma,
|
||||
// zodat het volume van de container een voorspelbaar pad heeft. Onze allowlist
|
||||
// komt in diezelfde map te staan, naast de database en niet erin.
|
||||
mkdirSync('data', { recursive: true });
|
||||
process.chdir('data');
|
||||
|
||||
const dataDir = process.cwd();
|
||||
|
||||
const port = Number.parseInt(process.env.RELAY_PORT ?? '4000', 10);
|
||||
|
||||
// Per schrijfactie, niet per eigenaar. Dat is hoe `isOwnerWithinQuota` bij Evolu
|
||||
// bedoeld is: de relay geeft door hoeveel bytes déze actie nodig heeft. Trezor
|
||||
// telt er zelf een totaal bij op uit hun eigen tabel; wij hebben die tabel niet en
|
||||
// doen dat dus niet.
|
||||
//
|
||||
// De standaard is dezelfde 1 MB als de gepubliceerde image, en dat is een bewuste
|
||||
// keuze en geen overname: een enkele labelwijziging is klein, dus een schrijfactie
|
||||
// die hierboven uitkomt is eerder een fout of misbruik dan normaal gebruik.
|
||||
const maxWriteBytes = Number.parseInt(process.env.RELAY_MAX_WRITE_BYTES ?? '1048576', 10);
|
||||
|
||||
const console = createConsole({
|
||||
formatter: createConsoleFormatter()({ timestampFormat: 'relative' }),
|
||||
});
|
||||
|
||||
// ── De staat ──────────────────────────────────────────────────────────────────
|
||||
// In het geheugen gezaghebbend, op schijf bewaard. `isOwnerAllowed` zit in het
|
||||
// verbindingspad, dus daar wordt niet naar schijf geschreven; dat doet de
|
||||
// achtergrondlus hieronder.
|
||||
|
||||
const start = readState(dataDir, new Date().toISOString());
|
||||
let state = start.state;
|
||||
let dirty = start.status === 'new';
|
||||
|
||||
if (start.status === 'corrupt') {
|
||||
console.log(
|
||||
'[warn] owners.json was unreadable and has been moved aside. Everyone is on the waiting list ' +
|
||||
'again; allow the owners you recognise on the status page.',
|
||||
);
|
||||
}
|
||||
|
||||
const markDirty = () => {
|
||||
dirty = true;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (!dirty) return;
|
||||
try {
|
||||
writeState(dataDir, state);
|
||||
dirty = false;
|
||||
} catch (error) {
|
||||
// Niet fataal: de relay blijft werken met de staat in het geheugen. Wel
|
||||
// melden, want na een herstart is de allowlist dan verouderd.
|
||||
console.log(`[warn] could not write owners.json: ${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Opdrachten van de statuspagina ────────────────────────────────────────────
|
||||
// De pagina schrijft een bestand, dit proces past het toe. Geen Docker-socket en
|
||||
// geen tweede container die in dezelfde staat schrijft; dezelfde afspraak als bij
|
||||
// Electrum Gate.
|
||||
|
||||
const pollCommands = () => {
|
||||
const command = takeCommand(dataDir);
|
||||
if (command === null) return;
|
||||
|
||||
const result = applyCommand(state, command, new Date().toISOString());
|
||||
if (result.error !== null) {
|
||||
console.log(`[warn] command refused: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
if (result.changed) {
|
||||
state = result.state;
|
||||
markDirty();
|
||||
console.log(`[info] command applied: ${command.action}`);
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
pollCommands();
|
||||
flush();
|
||||
}, 2000).unref();
|
||||
|
||||
const stopCleanly = () => {
|
||||
flush();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', stopCleanly);
|
||||
process.on('SIGINT', stopCleanly);
|
||||
|
||||
// ── De relay ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Eén regel per eigenaar per keer dat dit proces draait, en niet per verbinding.
|
||||
// Zonder deze rem staat het log vol zodra een cliënt opnieuw verbindt; zónder
|
||||
// logging kostte het op 28-08-2026 juist een half uur om te zien dat een tweede
|
||||
// eigenaar aan de deur stond te kloppen. De bovengrens erop is er omdat een
|
||||
// weigering een id bevat dat de ander zelf verzint.
|
||||
const MAX_ANNOUNCED = 100;
|
||||
const announced = new Set();
|
||||
|
||||
const announceOnce = (key, message) => {
|
||||
if (announced.has(key)) return;
|
||||
if (announced.size < MAX_ANNOUNCED) announced.add(key);
|
||||
console.log(message);
|
||||
};
|
||||
|
||||
await runMain({ ...createRelayDeps(), console })(
|
||||
createRelay({
|
||||
port,
|
||||
|
||||
isOwnerAllowed: (ownerId) => {
|
||||
let decision;
|
||||
try {
|
||||
decision = decideOwner(state, ownerId, new Date().toISOString());
|
||||
} catch (error) {
|
||||
// Bij twijfel weigeren. Een fout in het beleid mag geen open deur worden.
|
||||
console.log(`[warn] policy failed, refusing owner: ${String(error)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
state = decision.state;
|
||||
if (decision.changed) markDirty();
|
||||
|
||||
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
|
||||
if (decision.allowed) {
|
||||
announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
|
||||
} else {
|
||||
announceOnce(
|
||||
`no:${short}`,
|
||||
`[info] owner ${short} is on the waiting list (${decision.reason}); allow it on the ` +
|
||||
'status page if it is yours',
|
||||
);
|
||||
}
|
||||
return decision.allowed;
|
||||
},
|
||||
|
||||
isOwnerWithinQuota: (ownerId, requiredBytes) => {
|
||||
if (requiredBytes <= maxWriteBytes) return true;
|
||||
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
|
||||
announceOnce(
|
||||
`quota:${short}`,
|
||||
`[warn] refused a write of ${requiredBytes} bytes from ${short}; the limit is ` +
|
||||
`${maxWriteBytes}. Raise RELAY_MAX_WRITE_BYTES if this is normal for your app.`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,239 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
||||
// onbekende eigenaar.
|
||||
//
|
||||
// **Er is sinds 0.7.0 (09-09-2026) precies één weg naar binnen: de wachtlijst.**
|
||||
// Een onbekende eigenaar wordt geweigerd en onthouden; op de statuspagina staat
|
||||
// hij met zijn `OwnerId` erbij en daar laat je hem toe. De cliënt verbindt
|
||||
// vanzelf opnieuw en is dan binnen.
|
||||
//
|
||||
// Tot 0.6.0 was er daarnaast een getimede leerstand die de eerstvolgende
|
||||
// onbekende eigenaars toeliet. Die is eruit op voorstel van de gebruiker, en de
|
||||
// reden is dat het dubbelop was: allebei de wegen vragen iemand die bij de app
|
||||
// kan, en het venster is de zwakste van de twee omdat het iederéén toelaat die
|
||||
// er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek
|
||||
// wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je
|
||||
// eigen `OwnerId` nergens kon aflezen, en die reden verviel toen de weigerlijst
|
||||
// dat id ging tonen.
|
||||
//
|
||||
// De velden `learning` en `learningUntil` worden nog wél gelézen, en dat is met
|
||||
// opzet: `STATE_VERSION` verhogen zou `normalizeState` een bestaand bestand
|
||||
// laten afwijzen, en dan schuift `store.js` de allowlist van een werkende
|
||||
// installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
|
||||
// Ze worden gelezen, genegeerd en niet teruggeschreven.
|
||||
//
|
||||
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
|
||||
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
|
||||
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
|
||||
// er binnenkomt, en zulke logica hoort te testen te zijn zonder relay en zonder
|
||||
// Docker.
|
||||
//
|
||||
// De relay van Evolu roept `isOwnerAllowed(ownerId)` aan. Wij beantwoorden die
|
||||
// vraag hier; `src/index.js` doet niets anders dan lezen, doorgeven en opslaan.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const STATE_VERSION = 1;
|
||||
|
||||
// Hoeveel wachtenden we onthouden om op de pagina te tonen. Er zit een grens op
|
||||
// omdat een onbekende die blijft proberen anders het bestand vol schrijft: elke
|
||||
// poging is een eigenaar-id dat hij zelf verzint.
|
||||
//
|
||||
// Het veld heet op schijf nog `rejected` en dat blijft zo. Op de pagina heet het
|
||||
// sinds 0.7.0 de wachtlijst, want dat is wat het is: kandidaten die op een
|
||||
// beslissing wachten. De naam in het bestand hernoemen zou een migratie zijn, en
|
||||
// die is het niet waard voor een woord dat niemand ziet.
|
||||
export const MAX_REJECTED = 20;
|
||||
|
||||
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
|
||||
// buiten en belandt in een bestand. Daarom een bovengrens en een typecontrole, en
|
||||
// verder geen aannames over de vorm.
|
||||
export const MAX_OWNER_ID_LENGTH = 256;
|
||||
|
||||
export const createEmptyState = () => ({
|
||||
version: STATE_VERSION,
|
||||
owners: [],
|
||||
rejected: [],
|
||||
});
|
||||
|
||||
const isUsableOwnerId = (value) =>
|
||||
typeof value === 'string' && value.length > 0 && value.length <= MAX_OWNER_ID_LENGTH;
|
||||
|
||||
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
|
||||
|
||||
/**
|
||||
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
|
||||
*
|
||||
* Bewust streng: een half begrepen bestand is gevaarlijker dan geen bestand,
|
||||
* want dan denkt de app dat er een allowlist is terwijl die leeg is. De
|
||||
* aanroeper beslist wat er bij `null` gebeurt, en die keuze staat in `store.js`.
|
||||
*/
|
||||
export const normalizeState = (raw) => {
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
if (raw.version !== STATE_VERSION) return null;
|
||||
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
|
||||
|
||||
// `learning` en `learningUntil` mogen er nog in staan en worden overgeslagen.
|
||||
// Een bestand van 0.6.0 of ouder heeft ze, en dat bestand hoort gewoon te
|
||||
// lezen; zie de kop van dit bestand voor waarom hier geen versieverhoging
|
||||
// tegenover staat.
|
||||
|
||||
const owners = [];
|
||||
for (const owner of raw.owners) {
|
||||
if (owner === null || typeof owner !== 'object') return null;
|
||||
if (!isUsableOwnerId(owner.id)) return null;
|
||||
if (typeof owner.allowed !== 'boolean') return null;
|
||||
owners.push({
|
||||
id: owner.id,
|
||||
allowed: owner.allowed,
|
||||
firstSeen: typeof owner.firstSeen === 'string' ? owner.firstSeen : null,
|
||||
lastSeen: typeof owner.lastSeen === 'string' ? owner.lastSeen : null,
|
||||
});
|
||||
}
|
||||
|
||||
const rejected = [];
|
||||
for (const entry of raw.rejected) {
|
||||
if (entry === null || typeof entry !== 'object') return null;
|
||||
if (!isUsableOwnerId(entry.id)) return null;
|
||||
rejected.push({
|
||||
id: entry.id,
|
||||
firstSeen: typeof entry.firstSeen === 'string' ? entry.firstSeen : null,
|
||||
lastSeen: typeof entry.lastSeen === 'string' ? entry.lastSeen : null,
|
||||
attempts: Number.isInteger(entry.attempts) && entry.attempts > 0 ? entry.attempts : 1,
|
||||
});
|
||||
}
|
||||
|
||||
return { version: STATE_VERSION, owners, rejected };
|
||||
};
|
||||
|
||||
const rememberWaiting = (rejected, ownerId, now) => {
|
||||
const existing = rejected.find((entry) => entry.id === ownerId);
|
||||
if (existing) {
|
||||
return rejected.map((entry) =>
|
||||
entry.id === ownerId ? { ...entry, lastSeen: now, attempts: entry.attempts + 1 } : entry,
|
||||
);
|
||||
}
|
||||
// Nieuwste vooraan, en afkappen op MAX_REJECTED. De oudste valt eraf; dat is
|
||||
// hier de juiste kant om te verliezen, want een aanvaller die blijft proberen
|
||||
// duwt dan zijn eigen eerdere pogingen weg en niet de allowlist.
|
||||
return [{ id: ownerId, firstSeen: now, lastSeen: now, attempts: 1 }, ...rejected].slice(
|
||||
0,
|
||||
MAX_REJECTED,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Beslist of deze eigenaar erin mag, en geeft de staat terug zoals hij daarna is.
|
||||
*
|
||||
* @returns {{allowed: boolean, state: object, reason: string, changed: boolean}}
|
||||
*/
|
||||
export const decideOwner = (state, ownerId, now) => {
|
||||
if (!isUsableOwnerId(ownerId)) {
|
||||
// Geen bruikbaar id: weigeren en niets onthouden. Onthouden zou hier juist
|
||||
// de weg openzetten om het bestand vol te schrijven met rommel.
|
||||
return { allowed: false, state, reason: 'invalid-owner-id', changed: false };
|
||||
}
|
||||
|
||||
const known = findOwner(state, ownerId);
|
||||
|
||||
if (known) {
|
||||
const seen = { ...known, lastSeen: now };
|
||||
const owners = state.owners.map((owner) => (owner.id === ownerId ? seen : owner));
|
||||
return {
|
||||
allowed: known.allowed,
|
||||
state: { ...state, owners },
|
||||
reason: known.allowed ? 'known-allowed' : 'known-blocked',
|
||||
changed: known.lastSeen !== now,
|
||||
};
|
||||
}
|
||||
|
||||
// Onbekend: weigeren en op de wachtlijst zetten. Dat is sinds 0.7.0 de enige
|
||||
// uitkomst voor een onbekende, en daarmee ook de enige weg naar binnen: op de
|
||||
// statuspagina staat hij met zijn `OwnerId` erbij en daar laat je hem toe.
|
||||
return {
|
||||
allowed: false,
|
||||
state: { ...state, rejected: rememberWaiting(state.rejected, ownerId, now) },
|
||||
reason: 'waiting',
|
||||
changed: true,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Voert een opdracht van de statuspagina uit.
|
||||
*
|
||||
* De pagina schrijft een opdrachtbestand en dit proces past hem toe; er is geen
|
||||
* Docker-socket en geen tweede container die in dezelfde staat schrijft. Dat is
|
||||
* dezelfde afspraak als bij Electrum Gate.
|
||||
*
|
||||
* @returns {{state: object, changed: boolean, error: string|null}}
|
||||
*/
|
||||
export const applyCommand = (state, command, now) => {
|
||||
const unchanged = (error) => ({ state, changed: false, error });
|
||||
|
||||
if (command === null || typeof command !== 'object') return unchanged('malformed-command');
|
||||
|
||||
switch (command.action) {
|
||||
case 'block': {
|
||||
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
|
||||
const owners = state.owners.map((owner) =>
|
||||
owner.id === command.ownerId ? { ...owner, allowed: false, lastSeen: owner.lastSeen } : owner,
|
||||
);
|
||||
return { state: { ...state, owners }, changed: true, error: null };
|
||||
}
|
||||
|
||||
case 'allow': {
|
||||
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||
const known = findOwner(state, command.ownerId);
|
||||
// Ook een eigenaar die alleen als geweigerde poging bekend is, mag hiermee
|
||||
// alsnog toegelaten worden. Dat is de knop naast zo'n regel op de pagina.
|
||||
//
|
||||
// De geschiedenis van die pogingen gaat mee. Dat leek een detail tot het
|
||||
// op 28-08-2026 misging: de pagina meldde daarna "first seen" op het moment
|
||||
// van de klik, terwijl die eigenaar al twintig minuten aan de deur stond.
|
||||
// Juist bij een eigenaar die je alsnog toelaat wil je kunnen terugzien
|
||||
// sinds wanneer hij er is.
|
||||
const attempted = state.rejected.find((entry) => entry.id === command.ownerId);
|
||||
const owners = known
|
||||
? state.owners.map((owner) =>
|
||||
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
|
||||
)
|
||||
: [
|
||||
...state.owners,
|
||||
{
|
||||
id: command.ownerId,
|
||||
allowed: true,
|
||||
firstSeen: attempted && attempted.firstSeen ? attempted.firstSeen : now,
|
||||
lastSeen: attempted ? attempted.lastSeen : null,
|
||||
},
|
||||
];
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
owners,
|
||||
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
|
||||
},
|
||||
changed: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'forget': {
|
||||
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||
const inOwners = Boolean(findOwner(state, command.ownerId));
|
||||
const inRejected = state.rejected.some((entry) => entry.id === command.ownerId);
|
||||
if (!inOwners && !inRejected) return unchanged('unknown-owner');
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
owners: state.owners.filter((owner) => owner.id !== command.ownerId),
|
||||
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
|
||||
},
|
||||
changed: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return unchanged('unknown-action');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// De staat op schijf: de allowlist en het opdrachtbestand van de statuspagina.
|
||||
//
|
||||
// Dit is de enige plek met bestanden erin. Het beleid zelf staat in `policy.js`
|
||||
// en weet hier niets van.
|
||||
//
|
||||
// Waar het staat: onder `${APP_DATA_DIR}/data/`, náást de database van de relay
|
||||
// en niet erin. De relay bezit zijn eigen SQLite en daar schrijven wij niet in.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { createEmptyState, normalizeState } from './policy.js';
|
||||
|
||||
export const stateFileName = 'owners.json';
|
||||
export const commandFileName = 'command.json';
|
||||
|
||||
/**
|
||||
* Leest de allowlist.
|
||||
*
|
||||
* Drie uitkomsten, en de derde is de interessante:
|
||||
* - `new`: er is nog niets, dus een verse staat met de leerstand aan;
|
||||
* - `ok`: gelezen en begrepen;
|
||||
* - `corrupt`: er stond iets wat we niet begrijpen. Dan schuiven we het bestand
|
||||
* opzij en beginnen we met de leerstand **uit**.
|
||||
*
|
||||
* Die laatste keuze is bewust en volgt de regel die deze store al kent van
|
||||
* Electrum Gate: bij twijfel weigert de app. Een onleesbaar bestand betekent dat
|
||||
* we niet weten wie er toegelaten was; met de leerstand aan zou de eerstvolgende
|
||||
* die verbindt stilzwijgend de nieuwe eigenaar worden, en dat hoeft je eigen
|
||||
* apparaat niet te zijn. Dan liever een app die niemand toelaat en dat op de
|
||||
* pagina toont.
|
||||
*/
|
||||
export const readState = (dataDir, now) => {
|
||||
const file = join(dataDir, stateFileName);
|
||||
if (!existsSync(file)) return { state: createEmptyState(), status: 'new' };
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const state = parsed === null ? null : normalizeState(parsed);
|
||||
if (state !== null) return { state, status: 'ok' };
|
||||
|
||||
// Opzij schuiven en niet weggooien: als dit ooit gebeurt, wil je kunnen zien
|
||||
// wat erin stond.
|
||||
const aside = `${file}.corrupt-${now.replace(/[:.]/g, '-')}`;
|
||||
try {
|
||||
renameSync(file, aside);
|
||||
} catch {
|
||||
// Lukt het opzij schuiven niet, dan gaan we alsnog dicht. De melding hieronder
|
||||
// is dan het enige dat de gebruiker heeft, en dat is nog steeds beter dan
|
||||
// stilzwijgend iemand toelaten.
|
||||
}
|
||||
return { state: { ...createEmptyState(), learning: false }, status: 'corrupt' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Schrijft de allowlist. Eerst naar een tijdelijk bestand en dan hernoemen, zodat
|
||||
* een onderbroken schrijfactie geen half bestand achterlaat: hernoemen binnen
|
||||
* dezelfde map is atomair.
|
||||
*/
|
||||
export const writeState = (dataDir, state) => {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const file = join(dataDir, stateFileName);
|
||||
const temporary = `${file}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
renameSync(temporary, file);
|
||||
};
|
||||
|
||||
/**
|
||||
* Haalt een opdracht van de statuspagina op en verwijdert het bestand meteen.
|
||||
*
|
||||
* Verwijderen vóór het uitvoeren is met opzet: een opdracht die het proces laat
|
||||
* struikelen mag niet bij elke ronde opnieuw worden geprobeerd.
|
||||
*/
|
||||
export const takeCommand = (dataDir) => {
|
||||
const file = join(dataDir, commandFileName);
|
||||
if (!existsSync(file)) return null;
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
raw = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch {
|
||||
// Niets aan te doen; de volgende ronde probeert het opnieuw.
|
||||
}
|
||||
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** De map waarin de staat hoort, afgeleid van het pad van de relay-database. */
|
||||
export const dataDirectoryFor = (relayDatabaseFile) => dirname(relayDatabaseFile);
|
||||
Reference in new issue
Block a user