De pagina van Evolu Relay op de lijst van de gebruiker

Negen punten uit echt gebruik met 0.4.0, alle negen gedaan. Twee ervan waren
onderzoeksvragen en die staan onderaan.

Evolu Relay 0.5.0
- Een tijdvenster van twee minuten voor nieuwe eigenaars, met een stopknop. De
  teller zit in het relay-proces en niet in de pagina: een teller in een tabblad
  dat je sluit, sluit de deur niet. policy.js kreeg learningUntil, isLearningOpen
  en expireLearning.
- decideOwner kijkt naar isLearningOpen en niet naar het veld learning. De lus die
  een verlopen venster opruimt loopt elke twee seconden, en in dat gat zou een
  onbekende alsnog binnenkomen.
- Zonder STATE_VERSION te verhogen, met een toets die dat verdedigt: een verhoging
  zou de allowlist van de draaiende installatie laten afwijzen en de deur sluiten
  voor eigenaars die er al in stonden.
- Labels op een eigenaar-id, in een eigen labels.json met de agent als enige
  schrijver. Een label zegt niets over toegang, dus de relay hoeft het niet te
  weten; het is daardoor meteen opgeslagen en werkt ook als de relay omligt.
- Geblokkeerde en geweigerde eigenaars in een kader, met een badge die zegt welke
  van de twee het is. De badge staat buiten het hover-blok, anders is dat
  onderscheid onzichtbaar tenzij je over de regel gaat.
- Maatvoering gelijk aan Electrum Gate: 1760px, hetzelfde raster, icoon van 64
  pixels, dezelfde kop, versienummer erachter. Uitleg uit de kaders, knoppen pas
  bij hover, geen voetregel.

Electrum Gate 0.0.24
- Menu-item "About this app", in beide apps.
- De statuswidget zei "Answering" met "answered in 7 ms, from inside the app" en
  zegt nu "Running" met de meting eronder. De nuance dat de controle van container
  naar container loopt is verplaatst naar een eigen kopje in die dialoog, waar er
  ruimte voor is; vier woorden waren te weinig.

Toetsen en gereedschap
- tests/test_relay_agent.py (nieuw, 65 toetsen) en tests/test_paginas_parsen.mjs
  (nieuw). Muteertests gedraaid op de beslissende regels.
- Een dollarteken-toets in test_appstore_vorm.py. Het commentaar in drie bestanden
  beweerde al dat die test bestond; nu is dat waar.
- Een toets dat er geen werkbestanden in een app-map staan. umbreld kopieert de
  hele map naar het apparaat en in de back-up.
- tools/voorbeeldpagina.mjs maakt van een *.template een pagina die je in een
  browser kunt openen. Dat vond meteen twee echte opmaakfouten.

De twee onderzoeksvragen
- Een geweigerde eigenaar komt niet in de database: isOwnerAllowed zit in de
  WebSocket-upgrade, dus het is een 401 en een gesloten socket. Het gewenste gevolg
  treedt wel op, via de client: die is local-first en levert bij toelating de hele
  geschiedenis. Blokkeren werkt daarentegen pas bij de volgende verbinding, en dat
  staat als open punt.
- De blobs zijn niet met een xpub te ontcijferen; een OwnerId komt daar niet uit.
  Met de SLIP-21-node van het apparaat kan het wel, maar die geeft volledige
  zeggenschap, dus dat hoort niet in een relay. Als plan-punt opgenomen bij de tool
  in HomeGit/Trezor.

Nog niet uitgerold: de image 0.5.0 moet gebouwd en geduwd worden. De digest staat
daarom niet in de compose, want een oude digest onder een nieuwe tag levert stil de
oude relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-08-30 12:26:56 +02:00
co-authored by Claude Opus 5
parent 83af97c2c5
commit b3b1881af2
27 changed files with 3163 additions and 411 deletions
+285 -26
View File
@@ -21,6 +21,7 @@ import os
import socket
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -29,6 +30,18 @@ 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"))
@@ -43,6 +56,26 @@ 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.
@@ -95,6 +128,102 @@ def read_owners():
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()
@@ -106,9 +235,30 @@ def database_facts():
}
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 {
"relay": {
"reachable": probe["reachable"],
@@ -117,23 +267,29 @@ def build_status():
},
"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)
],
"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
@@ -160,7 +316,23 @@ def valid_command(payload):
value = payload.get("value")
if not isinstance(value, bool):
return None, "waarde moet true of false zijn"
return {"action": action, "value": value}, None
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:
@@ -168,6 +340,61 @@ def valid_command(payload):
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.
@@ -209,25 +436,57 @@ class Handler(BaseHTTPRequestHandler):
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
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
return None
if length <= 0 or length > MAX_BODY_BYTES:
self._send(400, {"error": "lege of te grote opdracht"})
return
return None
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
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)
+16 -5
View File
@@ -49,11 +49,22 @@ services:
# Voor de officiele store hoort er een multi-arch index-digest met arm64 in;
# zie het masterplan Publicatie-Relay.
#
# De digest hoort erbij en niet alleen de tag: een tag kan opnieuw geduwd
# worden en dan draait er iets anders dan hier staat. Staan ze allebei, dan
# bepaalt de digest wat er gehaald wordt en is de tag alleen leesbaarheid.
# Geduwd op 28-08-2026.
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.3.0@sha256:d2d8fcbe0e7cf41ca90acecde4a79f0c91181f61bd4692dd2c04027ee1c8450a
# ── LET OP: hier staat GEEN digest, en dat is tijdelijk ───────────────────
# 0.5.0 bestaat nog niet in het register: er zit een wijziging in de relay
# (het tijdvenster voor nieuwe eigenaars) en die moet eerst gebouwd en geduwd
# worden met `sh tools/evolu-relay/build.sh`.
#
# De digest van 0.3.0 stond hier en is weggehaald in plaats van blijven staan.
# Dat is met opzet de minst erge van twee kwaden: staat er een tag én een
# digest, dan bepaalt de DIGEST wat er gehaald wordt. Een oude digest onder
# een nieuwe tag levert dus stilzwijgend de oude relay, en dan werkt de timer
# niet zonder dat iets dat meldt. Ongepind faalt hard en zichtbaar zolang de
# image er niet is, en dat is hier het gedrag dat je wil.
#
# Zet de digest uit de push-uitvoer er weer achter zodra 0.5.0 geduwd is;
# tests/test_appstore_vorm.py drukt de pinstatus af, dus de suite blijft het
# zeggen tot het gedaan is.
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.5.0
restart: on-failure
ports:
# De relay zelf. Dit is de poort waar Zoraxy met TLS naartoe wijst, en de
File diff suppressed because it is too large Load Diff
+27 -7
View File
@@ -11,7 +11,7 @@ manifestVersion: 1
id: whatsnext-evolu-relay
category: files
name: Evolu Relay
version: "0.4.0"
version: "0.5.0"
tagline: Encrypted sync and backup for your local-first apps
description: >-
Local-first apps keep your data on your own device and work whether or not you have a
@@ -43,19 +43,39 @@ description: >-
your app at the address shown there. Away from home you will need a way in, such as Tailscale
or a reverse proxy with your own domain.
releaseNotes: >-
Fixes a status page that alternated between working and showing an error. It reached its helper by a
short name that another app in this store uses as well, so about half of its requests ended up at the
wrong app. It now uses a name that is unique to this one.
Accepting a new owner is now a two minute window instead of a switch you have to remember to turn
back off. Open it, pair your device, and it closes on its own; you can close it early or restart the
two minutes if you need longer. The countdown runs on the Umbrel, so closing the page does not leave
the door open.
This release also drops the wording that tied the app to one particular client. It is a general
purpose Evolu relay: any app built on Evolu can use it. And the explanation under the new owners
switch now matches the switch: it used to describe the open state even when it was closed.
Owner ids can be given a name. They are long strings of random characters that say nothing about
which of your devices or wallets they are, so hover a row and label it. Names are kept on your Umbrel
and are never sent anywhere.
Blocked owners and refused attempts were two lists and are now one, because what you do with them is
the same: allow, or forget. Each row carries a badge saying which it was.
The page itself has been rebuilt to match Electrum Gate in this store: the same width, the same
layout, the same header, and the version number where you can see it. Buttons appear when you hover a
row, so a page you are only reading stays quiet. Explanatory paragraphs have moved into a new
"About this app" item in the menu, which also spells out what the relay can and cannot see.
Earlier releases:
0.4.0 fixed a status page that alternated between working and showing an error. It reached its helper
by a short name that another app in this store uses as well, so about half of its requests ended up
at the wrong app.
0.4.0 also dropped the wording that tied the app to one particular client. It is a general purpose
Evolu relay: any app built on Evolu can use it.
0.2.0 replaced the relay this app shipped with. Until 0.0.2 it packaged a vendor's own deployment,
which came with a quota manager and a PostgreSQL database and could not work outside that vendor's
service: clients skip the quota manager as soon as you point them at a relay of your own, while