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:
HarmenandClaude Opus 5.5 committed 2026-10-09 10:40:25 +02:00
commit 85d85a1295
63 files changed
+13987

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# De bouwcontext van tools/*/build.sh is de repo-root, omdat elke Dockerfile
# icon.png uit zijn app-map kopieert. Alles wat de Dockerfiles niet noemen blijft
# hier buiten, zodat de context klein is en een wijziging in de documentatie de
# bouwcache niet ongeldig maakt.
*
!tools/electrum-gate
!whatsnext-electrum-gate/icon.png
!tools/evolu-relay
!whatsnext-evolu-relay/icon.png
tools/evolu-relay/node_modules
+50
View File
@@ -0,0 +1,50 @@
# ═══════════════════════════════════════════════════════════════════════════════
# .gitattributes - UmbrelApps
#
# Waarom dit bestand er is, en meteen bij het opzetten en niet later: op deze
# Windows-machine staat core.autocrlf vaak op true. Zonder deze regels waarschuwt
# git bij elke `git add` dat "LF will be replaced by CRLF", en komt er CRLF/LF-ruis
# in diffs zodra er afwisselend met een editor en met CLI-tooling gewerkt wordt.
#
# Voor dit project is er een hardere reden dan diff-ruis. De shellscripts en de
# YAML worden op een Umbrel in een Linux-container uitgevoerd. Een CRLF achter
# `#!/bin/sh` laat het script stuklopen op een foutmelding die nergens naar het
# echte probleem wijst, en een CRLF in docker-compose.yml geeft waarden met een
# onzichtbare wagenterugloop erin. Dit bestand voorkomt allebei.
# ═══════════════════════════════════════════════════════════════════════════════
# Standaard: git detecteert tekst vs. binair, slaat tekst op als LF en checkt ook
# LF uit. Dat houdt de werkmap identiek aan wat er in de repo staat.
* text=auto eol=lf
# ── Alles wat op de Umbrel draait: LF, zonder uitzondering ────────────────────
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.conf text eol=lf
*.sql text eol=lf
*.template text eol=lf
# ── Broncode en documentatie expliciet als tekst ──────────────────────────────
# Strikt genomen dekt `text=auto` dit al. Toch opschrijven: het maakt zichtbaar
# waar dit project uit bestaat, en het haalt de gok uit de detectie voor een
# bestand dat toevallig op een binair patroon lijkt.
*.md text
*.txt text
*.html text
*.css text
*.js text
*.json text
Dockerfile text eol=lf
# ── Binair: nooit converteren, geen tekst-diff ────────────────────────────────
# Het icoon en de gallery-afbeeldingen komen hier bij het plan Appstore.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.svg text
*.pem binary
*.key binary
+93
View File
@@ -0,0 +1,93 @@
# ── Geheimen: nooit in de repo ────────────────────────────────────────────────
# Deze repo wordt publiek, want umbreld kloont een community app store anoniem.
# Certificaten en sleutels horen bij Zoraxy op de Umbrel en komen hier nooit in,
# ook niet "even om te testen".
*.pem
*.key
*.crt
*.p12
*.pfx
secrets/
.env
.env.local
.env.*.local
.env.*
!.env.sample
# ── Data: nooit in de repo, en niet inlezen ───────────────────────────────────
# Evolu Relay krijgt een Postgres, en de proefopstelling zet lokaal een datamap
# neer. Die hoort nergens in een repo thuis, ook niet in een private, en hij
# wordt ook niet ingelezen: HomeGit/CLAUDE.md, "Data van de gebruiker".
pgdata/
postgres-data/
*.sqlite
*.sqlite3
*.db
*.dump
*.sql.gz
# ── Runtime-toestand van de app ───────────────────────────────────────────────
.cert-timestamp
status.json
*.pid
*.log
# ── Werkbestanden ─────────────────────────────────────────────────────────────
*.tmp
tmp/
temp/
*.bak
*.backup
*~
# ── Editor en OS ──────────────────────────────────────────────────────────────
.vscode/
.idea/
*.sublime-*
*.swp
*.swo
.DS_Store
Thumbs.db
# ── Claude Code: lokaal, niet in de publieke repo ─────────────────────────────
# De werkafspraken staan in HomeGit/Docs/Referenties/UmbrelApps/; zie de
# uitzondering in HomeGit/CLAUDE.md.
.claude/
CLAUDE.md
Docs/
# ── Bouwsel ───────────────────────────────────────────────────────────────────
dist/
build/
*.tar.gz
*.zip
# ── Node ──────────────────────────────────────────────────────────────────────
# Zowel tools/evolu-relay/ als tools/relay-client/ hebben een package.json. Wat
# daar geïnstalleerd wordt hoort niet in de repo: de relay bouwt in zijn image en
# de testcliënt installeert lokaal.
node_modules/
# De gegevens van de testcliënt: per eigenaar een mnemonic en een database. Dit
# is sleutelmateriaal, en ook al heeft het buiten de proefopstelling geen waarde,
# een publieke repo is er geen plek voor.
tools/relay-client/data/
tools/relay-client/spike-data/
# De staat van de Actions-runner. Daarin ligt `.runner`, en dat bestand draagt het
# token waarmee die runner zich bij Gitea meldt: sleutelmateriaal, dus nooit in de
# repo, ook niet in een private.
tools/gitea-runner/data/
# ── Python ────────────────────────────────────────────────────────────────────
# De test importeert tools/electrum-gate/agent.py als module, en Python legt daar
# dan bytecode naast. De test zet dat zelf uit, maar dit is de vangnetregel: zo'n
# .pyc glipte een keer mee in een commit.
__pycache__/
*.py[cod]
# De uitvoer van tools/voorbeeldpagina.mjs: een statuspagina met de variabelen
# ingevuld en nagemaakte gegevens erin, om in een browser te bekijken. Genereerbaar
# uit de template, dus er is niets te bewaren, en het zou een tweede versie van de
# pagina in de repo zetten die stilzwijgend achterloopt.
voorbeeld/
+15
View File
@@ -0,0 +1,15 @@
# UmbrelApps
A community app store for [Umbrel](https://umbrel.com), store id `whatsnext`. Add it in umbrelOS under
**App Store → Community App Stores**, using the clone URL of this repository.
| App | What it does |
|-|-|
| **Electrum Gate** | Reach your own Electrum server from outside your network, over TLS. |
| **Evolu Relay** | Run the sync relay behind Trezor Suite's labels on your own Umbrel, with an owner allowlist. |
| **Atreides Assets** | A self-hosted overview of your crypto and precious metals. |
The image recipes for the first two are in [tools/](tools/); the tests are in [tests/](tests/).
These apps are built for personal use. They are thin layers around existing software (nginx, Alpine Linux,
`@evolu/nodejs`), each under its own license. No license has been chosen yet for the packaging itself.
+629
View File
@@ -0,0 +1,629 @@
"""Toetst de certificaatlezer en de keuze-guard van de agent.
Waarom deze test bestaat: de agent leest een X.509-certificaat met een eigen
DER-lezer, omdat de standaardbibliotheek er geen heeft en de app bij het starten
niets bijinstalleert. Zo'n lezer faalt niet met een foutmelding maar met een
verkeerd antwoord, en een certificaatdatum die er een jaar naast zit valt nooit
op. Daarom wordt hij hier vergeleken met `ssl`, dat een onafhankelijke
implementatie is.
Draaien:
python tests/test_agent_certificates.py
De test laadt `tools/electrum-gate/agent.py` rechtstreeks: het bestand dat de
Dockerfile in de image zet. Tot 0.1.0 heette dat `agent.py.template` en stond het
in de app-map; de eerste toets hieronder is uit die tijd en blijft staan, want
het script haalt zijn instellingen nog altijd uit de omgeving en niet uit een
ingevulde constante.
"""
import sys
# Vóór de imports, want anders is het te laat: Python legt bytecode naast agent.py
# zodra die geïmporteerd wordt, en die rommel hoort niet in de repo. Een keer is
# zo'n .pyc meegegaan in een commit.
sys.dont_write_bytecode = True
import importlib.machinery # noqa: E402
import importlib.util # noqa: E402
import os # noqa: E402
import shutil # noqa: E402
import socket # noqa: E402
import ssl # noqa: E402
import subprocess # noqa: E402
import tempfile # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
AGENT = os.path.join(HERE, os.pardir, "tools", "electrum-gate", "agent.py")
def load_agent():
"""Laadt agent.py als module, buiten sys.path om."""
loader = importlib.machinery.SourceFileLoader("gate_agent", AGENT)
spec = importlib.util.spec_from_file_location("gate_agent", AGENT, loader=loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def der(tag, inhoud):
"""Eén DER-element, met de lengte in korte of lange vorm."""
if len(inhoud) < 0x80:
return bytes([tag, len(inhoud)]) + inhoud
lengte = len(inhoud).to_bytes((len(inhoud).bit_length() + 7) // 8, "big")
return bytes([tag, 0x80 | len(lengte)]) + lengte + inhoud
def nep_certificaat(tijdtag, tijdtekst):
"""Het kleinste certificaat waarin not_after zijn validity vindt."""
tijd = der(tijdtag, tijdtekst.encode("ascii"))
return der(0x30, der(0x30, der(0x30, tijd + tijd)))
def test_template_is_invulbaar_zonder_schade(u):
"""Een restant uit de template-tijd dat blijft staan.
Het bestand zit sinds 0.1.0 in de image en gaat door geen envsubst meer. Maar
een accolade-variabele in Python-code is ook zonder umbreld een fout, en als
iemand dit bestand ooit weer als template in een app-map zet, is dit de toets
die het meteen ziet.
"""
with open(AGENT, "r", encoding="utf-8") as f:
inhoud = f.read()
u.check("agent.py bevat geen accolade-variabelen",
"${" not in inhoud,
"een envsubst-stap zou die invullen en de Python-code slopen")
def test_einddatum_gelijk_aan_ssl(agent, u):
"""De echte certificaten uit de certificaatwinkel van het besturingssysteem.
Dit is de belangrijkste toets, want `ssl` levert hier het antwoordblad. Zijn
er geen certificaten te vinden, dan wordt dit overgeslagen en gemeld, in
plaats van stil als geslaagd geteld.
"""
ctx = ssl.create_default_context()
geparseerd = ctx.get_ca_certs(binary_form=False)
ders = ctx.get_ca_certs(binary_form=True)
if not ders or len(ders) != len(geparseerd):
print("OVERGESLAGEN: geen CA-certificaten beschikbaar op dit systeem, "
"dus de vergelijking met ssl is niet gedaan")
return
afwijkingen = []
for i, d in enumerate(ders):
verwacht = int(ssl.cert_time_to_seconds(geparseerd[i]["notAfter"]))
try:
gekregen = agent.not_after(d)
except Exception as e: # noqa: BLE001
afwijkingen.append("cert %d wierp %r" % (i, e))
continue
if gekregen != verwacht:
afwijkingen.append("cert %d: verwacht %d, kreeg %d"
% (i, verwacht, gekregen))
u.check("einddatum van %d CA-certificaten gelijk aan ssl" % len(ders),
not afwijkingen, "; ".join(afwijkingen[:3]))
def test_beide_tijdvormen(agent, u):
"""UTCTime en GeneralizedTime, en beide takken van de eeuwregel.
Het CA-materiaal gebruikt uitsluitend UTCTime met jaren na 2000, dus zonder
deze zelfgebouwde certificaten blijven twee takken ongetoetst.
"""
gevallen = [
(0x18, "20700101000000Z", "Jan 1 00:00:00 2070 GMT"),
(0x18, "99991231235959Z", "Dec 31 23:59:59 9999 GMT"),
(0x17, "491231235959Z", "Dec 31 23:59:59 2049 GMT"),
(0x17, "500101000000Z", "Jan 1 00:00:00 1950 GMT"),
]
for tag, tekst, ssl_tekst in gevallen:
verwacht = int(ssl.cert_time_to_seconds(ssl_tekst))
gekregen = agent.not_after(nep_certificaat(tag, tekst))
u.check("tijd %s" % tekst, gekregen == verwacht,
"verwacht %d, kreeg %d" % (verwacht, gekregen))
def test_tijd_zonder_zulu_faalt_hard(agent, u):
"""Het niet-gelukkige pad: een tijd die DER niet toestaat.
Dit is met opzet een uitzondering en geen benadering. Een certificaatdatum die
er een uur naast zit, valt nooit op.
"""
slecht = nep_certificaat(0x18, "20700101000000")
try:
agent.not_after(slecht)
u.check("tijd zonder Z faalt", False, "er kwam een antwoord uit")
except ValueError:
u.check("tijd zonder Z faalt", True)
except Exception as e: # noqa: BLE001
u.check("tijd zonder Z faalt", False, "verkeerde uitzondering: %r" % e)
def test_certificaat_zonder_validity_faalt(agent, u):
"""Een certificaat waar niets in staat, mag geen datum opleveren."""
leeg = der(0x30, der(0x30, der(0x02, b"\x01")))
try:
agent.not_after(leeg)
u.check("geen validity faalt", False, "er kwam een antwoord uit")
except ValueError:
u.check("geen validity faalt", True)
def test_live_certificaat_met_san(agent, u):
"""Een echt servercertificaat, want CA-certificaten hebben geen dNSName.
Zonder netwerk wordt dit overgeslagen en blijft subjectAltName ongetoetst.
Dat is gemeld, niet verzwegen.
"""
try:
with ssl.create_default_context().wrap_socket(
socket.create_connection(("example.com", 443), timeout=6),
server_hostname="example.com",
) as s:
binair = s.getpeercert(binary_form=True)
info = s.getpeercert()
except Exception as e: # noqa: BLE001
print("OVERGESLAGEN: geen verbinding voor de SAN-toets (%s), "
"subjectAltName blijft dus ongetoetst" % e)
return
verwacht = sorted(v for k, v in info.get("subjectAltName", ()) if k == "DNS")
gekregen = sorted(agent.dns_names(binair))
u.check("subjectAltName gelijk aan ssl", gekregen == verwacht,
"verwacht %r, kreeg %r" % (verwacht[:3], gekregen[:3]))
u.check("einddatum van een levend certificaat gelijk aan ssl",
agent.not_after(binair) == int(ssl.cert_time_to_seconds(info["notAfter"])))
def test_keuze_negeert_onbekende_id(agent, u):
"""De guard. Dit is het pad waar een fout stil TLS zou uitschakelen.
Wijst de keuze naar iets wat niet bestaat, dan mag de app niet doen alsof.
Hij valt terug op wat er wel is, en meldt dat.
"""
with tempfile.TemporaryDirectory() as tmp:
agent.CONFIG_DIR = tmp
agent.SELECTED_FILE = os.path.join(tmp, "selected-cert")
nu = 2_000_000_000
certs = [
{"id": "zoraxy/kort", "source": "zoraxy", "name": "kort",
"cert": "/x", "key": "/y", "not_after": nu + 86400},
{"id": "zoraxy/lang", "source": "zoraxy", "name": "lang",
"cert": "/a", "key": "/b", "not_after": nu + 400 * 86400},
]
agent.write_selection("zoraxy/bestaat-niet")
gekozen, waarom = agent.choose(certs)
u.check("onbekende keuze wordt niet gehonoreerd",
gekozen is None, "koos %r" % (gekozen and gekozen["id"]))
u.check("en er wordt ook niet zomaar een ander gepakt",
"2 certificates found" in waarom, "waarom was %r" % waarom)
# De reden noemt het aantal en niet de namen. Op de Umbrel van de
# gebruiker zijn dat er veertien, en die opsomming maakte van de melding
# een muur tekst die zegt wat de keuzelijst eronder al toont.
u.check("de reden somt de kandidaten niet op",
"zoraxy/kort" not in waarom and "zoraxy/lang" not in waarom,
"waarom was %r" % waarom)
# Eén kandidaat is geen keuze, dus die mag wel automatisch.
gekozen, waarom = agent.choose(certs[:1])
u.check("bij precies één certificaat kiest de app zelf",
gekozen is not None and gekozen["id"] == "zoraxy/kort",
"koos %r" % (gekozen and gekozen["id"]))
agent.write_selection("zoraxy/kort")
gekozen, waarom = agent.choose(certs)
u.check("een bestaande keuze wint van de automaat",
gekozen["id"] == "zoraxy/kort", "koos %r" % gekozen["id"])
onleesbaar = [dict(certs[0], unreadable="stuk")]
gekozen, waarom = agent.choose(onleesbaar)
u.check("een onleesbaar certificaat wordt niet gekozen",
gekozen is None, "koos %r" % (gekozen and gekozen["id"]))
def test_keuze_zonder_geldig_certificaat(agent, u):
"""Alles verlopen: dan liever niets dan een verlopen certificaat opdringen."""
with tempfile.TemporaryDirectory() as tmp:
agent.CONFIG_DIR = tmp
agent.SELECTED_FILE = os.path.join(tmp, "selected-cert")
verlopen = [{"id": "own/oud", "source": "own", "name": "oud",
"cert": "/a", "key": "/b", "not_after": 1}]
gekozen, waarom = agent.choose(verlopen)
u.check("alles verlopen levert geen keuze op", gekozen is None)
u.check("en de reden wordt genoemd", "expired" in waarom,
"waarom was %r" % waarom)
gekozen, waarom = agent.choose([])
u.check("een lege lijst levert geen keuze op", gekozen is None)
def test_paren_vinden(agent, u):
"""De drie naamvormen van de bronnen, in een echte map."""
with tempfile.TemporaryDirectory() as tmp:
# Zoraxy-vorm
open(os.path.join(tmp, "a.example.org.pem"), "w").close()
open(os.path.join(tmp, "a.example.org.key"), "w").close()
# Een certificaat zonder sleutel hoort niet mee te komen
open(os.path.join(tmp, "b.example.org.pem"), "w").close()
# Nginx Proxy Manager-vorm
os.mkdir(os.path.join(tmp, "npm-7"))
open(os.path.join(tmp, "npm-7", "fullchain.pem"), "w").close()
open(os.path.join(tmp, "npm-7", "privkey.pem"), "w").close()
# Een map zonder sleutel hoort ook niet mee te komen
os.mkdir(os.path.join(tmp, "npm-8"))
open(os.path.join(tmp, "npm-8", "fullchain.pem"), "w").close()
namen = sorted(n for n, _, _ in agent._pairs_in(tmp))
u.check("alleen volledige paren", namen == ["a.example.org", "npm-7"],
"vond %r" % namen)
u.check("een map die niet bestaat levert een lege lijst",
agent._pairs_in(os.path.join(tmp, "weg")) == [])
def openssl_paar(map_, naam, cn, dagen=365, wachtwoord=None, san=True):
"""Een echt zelfondertekend paar. Geeft (certpad, keypad) of None.
Echt en niet zelfgebouwd, want de controle die hier getoetst wordt is die van
OpenSSL zelf: hoort deze sleutel bij dit certificaat. Een met de hand
geknutseld DER-certificaat heeft geen sleutel en bewijst daar dus niets over.
"""
if not shutil.which("openssl"):
return None
cert = os.path.join(map_, naam + ".crt")
key = os.path.join(map_, naam + ".key")
opdracht = [
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", key, "-out", cert, "-days", str(dagen),
# De schuine streep is in -subj het scheidingsteken tussen velden, dus een
# hostnaam die er zelf een bevat moet ontsnapt worden. Zonder dit sloeg de
# padtruc-toets zichzelf stil over, en dat is precies de toets die er moet
# zijn.
"-subj", "/CN=" + cn.replace("/", "\\/"),
]
if san:
opdracht += ["-addext", "subjectAltName=DNS:" + cn]
if wachtwoord:
opdracht += ["-passout", "pass:" + wachtwoord]
else:
opdracht += ["-nodes"]
klaar = subprocess.run(opdracht, capture_output=True)
if klaar.returncode != 0:
# De laatste regel, want openssl vult stderr met voortgangspuntjes en de
# echte reden staat onderaan.
regels = [r for r in klaar.stderr.decode("utf-8", "replace").splitlines()
if r.strip()]
print("OVERGESLAGEN: openssl gaf een fout bij %s (%s)"
% (naam, regels[-1][:160] if regels else "geen melding"))
return None
return cert, key
class Tijdreis:
"""Een tijdmodule die alleen over `time()` liegt.
Nodig om de vervaldatum-guard te toetsen: een certificaat dat al verlopen ís
kan openssl 3.1 niet maken, en de datum vooruitzetten kan wel.
"""
def __init__(self, echt, wanneer):
self._echt = echt
self._wanneer = wanneer
def time(self):
return self._wanneer
def __getattr__(self, naam):
return getattr(self._echt, naam)
def test_upload(agent, u):
"""Het uploadpad, en vooral wat het weigert.
Dit is het enige pad waarlangs iets van buiten bestanden neerzet, dus de
toetsen gaan over de niet-gelukkige gevallen. De duurste fout die hier
voorkomen moet worden is een sleutel die niet bij het certificaat hoort: die
levert een nginx op die niet meer herlaadt, en dat is dezelfde klasse storing
als waardoor 0.0.3 helemaal niet startte.
"""
with tempfile.TemporaryDirectory() as tmp:
bron = os.path.join(tmp, "bron")
doel = os.path.join(tmp, "own")
os.makedirs(bron)
agent.UPLOAD_DIR = doel
agent.SOURCES = [("Own folder", doel)]
# Uit zonder map. Eerst, want dit hoort niet stil door te vallen naar een
# pad dat toevallig bestaat.
agent.UPLOAD_DIR = ""
naam, fout = agent.accept_upload("x", "y")
u.check("zonder uploadmap weigert de agent",
naam is None and "switched off" in (fout or ""),
"gaf %r, %r" % (naam, fout))
agent.UPLOAD_DIR = doel
naam, fout = agent.accept_upload("hallo", "-----BEGIN PRIVATE KEY-----")
u.check("rommel in plaats van een certificaat wordt geweigerd",
naam is None and "does not contain a certificate" in (fout or ""),
"gaf %r, %r" % (naam, fout))
naam, fout = agent.accept_upload(
"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----", "hallo")
u.check("rommel in plaats van een sleutel wordt geweigerd",
naam is None and "does not contain a private key" in (fout or ""),
"gaf %r, %r" % (naam, fout))
u.check("een weigering laat niets achter in de doelmap",
not os.path.isdir(doel) or os.listdir(doel) == [],
"er staat: %r" % (os.path.isdir(doel) and os.listdir(doel)))
paar = openssl_paar(bron, "goed", "gate.example.org")
if paar is None:
print("OVERGESLAGEN: geen openssl, dus het gelukkige pad en de "
"sleutelcontrole van deze toets blijven ongetoetst")
return
cert_tekst = open(paar[0], encoding="ascii").read()
key_tekst = open(paar[1], encoding="ascii").read()
# De belangrijkste toets van dit bestand na de datumvergelijking: een
# sleutel van een ánder paar hoort hier te stranden.
ander = openssl_paar(bron, "ander", "andere.example.org")
naam, fout = agent.accept_upload(cert_tekst,
open(ander[1], encoding="ascii").read())
u.check("een sleutel van een ander paar wordt geweigerd",
naam is None and "does not go with" in (fout or ""),
"gaf %r, %r" % (naam, fout))
u.check("en ook dan blijft de doelmap leeg",
not os.path.isdir(doel) or os.listdir(doel) == [],
"er staat: %r" % (os.path.isdir(doel) and os.listdir(doel)))
# Een versleutelde sleutel: nginx kan die niet openen zonder wachtwoord.
met_slot = openssl_paar(bron, "slot", "slot.example.org",
wachtwoord="geheim")
if met_slot:
naam, fout = agent.accept_upload(
open(met_slot[0], encoding="ascii").read(),
open(met_slot[1], encoding="ascii").read())
u.check("een versleutelde sleutel wordt geweigerd",
naam is None and "encrypted" in (fout or ""),
"gaf %r, %r" % (naam, fout))
# Verlopen. De klok gaat vooruit in plaats van het certificaat achteruit.
echt = agent.time
agent.time = Tijdreis(echt, echt.time() + 400 * 86400)
try:
naam, fout = agent.accept_upload(cert_tekst, key_tekst)
finally:
agent.time = echt
u.check("een verlopen certificaat wordt geweigerd",
naam is None and "expired on" in (fout or ""),
"gaf %r, %r" % (naam, fout))
# Een certificaat zonder bruikbare naam: er is dan niets om een wallet
# tegen te vergelijken, dus plaatsen heeft geen zin.
naamloos = openssl_paar(bron, "naamloos", "!!!", san=False)
if naamloos:
naam, fout = agent.accept_upload(
open(naamloos[0], encoding="ascii").read(),
open(naamloos[1], encoding="ascii").read())
u.check("een certificaat zonder hostnaam wordt geweigerd",
naam is None and "no host name" in (fout or ""),
"gaf %r, %r" % (naam, fout))
# En dan het gelukkige pad.
naam, fout = agent.accept_upload(cert_tekst, key_tekst)
u.check("een geldig paar wordt aangenomen",
naam == "gate.example.org" and fout is None,
"gaf %r, %r" % (naam, fout))
u.check("en staat als paar in de doelmap",
sorted(os.listdir(doel)) == ["gate.example.org.key",
"gate.example.org.pem"],
"er staat: %r" % sorted(os.listdir(doel)))
u.check("de scanner vindt het meteen",
[n for n, _, _ in agent._pairs_in(doel)] == ["gate.example.org"])
u.check("de id verwijst naar de bron uit de omgeving",
agent.upload_id(naam) == "Own folder/gate.example.org",
"gaf %r" % agent.upload_id(naam))
# Nog een keer hetzelfde: dat vervangt, en laat geen .tmp achter. Een
# vernieuwd certificaat is precies waarom dit pad bestaat.
naam, fout = agent.accept_upload(cert_tekst, key_tekst)
u.check("hetzelfde nog eens vervangt zonder klagen",
naam == "gate.example.org" and fout is None,
"gaf %r, %r" % (naam, fout))
u.check("en er blijft geen tijdelijk bestand liggen",
not [n for n in os.listdir(doel) if n.endswith(".tmp")],
"er staat: %r" % sorted(os.listdir(doel)))
# Een naam met padtrucs erin kan niet uit het doel ontsnappen, want de
# naam komt uit het certificaat en niet uit het verzoek.
stout = openssl_paar(bron, "stout", "../../etc/nginx/evil", san=False)
if stout:
naam, fout = agent.accept_upload(
open(stout[0], encoding="ascii").read(),
open(stout[1], encoding="ascii").read())
u.check("een hostnaam met padtekens levert een platte naam op",
fout is None and "/" not in (naam or "/") and ".." not in (naam or ".."),
"gaf %r, %r" % (naam, fout))
u.check("en het bestand staat in de doelmap zelf",
fout is not None or os.path.isfile(
os.path.join(doel, naam + ".pem")))
def test_sessieregels_indelen(agent, u):
"""De drie soorten sessieregels uit de nginx-log.
Het onderscheid tussen `probe` en `refused` is het punt: een doorgestuurde
poort wordt gescand, en zonder dat onderscheid gaf elke scan een rode regel
"refused" terwijl er niets geweigerd is. De grens ligt bij de bytes en niet bij
de duur of de status, en dat is precies wat hier vastligt.
"""
with tempfile.TemporaryDirectory() as tmp:
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
with open(agent.STREAM_LOG, "w", encoding="utf-8") as f:
# Een echte sessie, een scan, en een verbinding die wél iets
# doorgaf en daarna omviel.
f.write("2026-08-20T12:39:29+02:00 200 95300 218000 240.5\n")
f.write("2026-08-20T12:38:29+02:00 500 0 0 1.001\n")
f.write("2026-08-20T12:40:29+02:00 502 120 0 0.5\n")
# Een scan die lang open bleef staan: nog steeds een scan.
f.write("2026-08-20T12:41:29+02:00 500 0 0 45.0\n")
f.write("rommel die geen sessieregel is\n")
regels = agent.read_stream_log(0)
soorten = [r["event"] for r in regels]
u.check("de drie soorten komen er in de juiste orde uit",
soorten == ["disconnect", "probe", "refused", "probe"],
"gaf %r" % soorten)
u.check("een onleesbare regel wordt overgeslagen", len(regels) == 4,
"gaf %d regels" % len(regels))
probe = regels[1]
u.check("de scan houdt zijn status in de notitie",
probe.get("note") == "nothing exchanged, status 500",
"gaf %r" % probe.get("note"))
u.check("en zijn bytes staan op nul",
probe["bytes_in"] == 0 and probe["bytes_out"] == 0)
stuk = regels[2]
u.check("een sessie die wél verkeer had heet refused",
stuk.get("note") == "session ended with status 502",
"gaf %r" % stuk.get("note"))
echt = regels[0]
# 240 en niet 241: round() in Python rondt een halve naar even af.
u.check("een geslaagde sessie heeft geen notitie nodig",
"note" not in echt and echt["seconds"] == 240,
"gaf %r" % echt)
def test_open_verbindingen(agent, u):
"""De verbindingsteller, en de logregel die eruit volgt.
Dit gaat via `build_status`, dus via de ronde die de agent echt draait, en
niet via een interne helper. Twee dingen moeten kloppen die makkelijk stil
fout gaan: een teller die er niet is mag geen nul worden, en een
connect-regel moet de volgende ronde overleven. Die laatste zat er eerst
naast: `build_status` neemt de eigen gebeurtenissen van de vorige ronde over
aan de hand van een opsomming van soorten, en `connect` stond er niet in.
Een regel die na een minuut weer verdwijnt is nutteloos.
"""
with tempfile.TemporaryDirectory() as tmp:
agent.SOURCES = []
agent.ELECTRUM_HOST = "" # dan faalt de probe meteen, zonder netwerk
agent.STATE_DIR = tmp
agent.CONFIG_DIR = os.path.join(tmp, "config")
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
agent.STATUS_FILE = os.path.join(tmp, "status.json")
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
def ronde():
status = agent.build_status([])
agent.write_status(status)
return status
# Geen tellerbestand: dan hoort er niets in de status te staan. Nul zou
# hier "geen wallet verbonden" beweren op grond van niets.
status = ronde()
u.check("zonder teller geen bewering over verbindingen",
"open_connections" not in status["tls"],
"stond er: %r" % status["tls"])
def zet_teller(tekst):
with open(agent.SESSIONS_FILE, "w", encoding="utf-8") as f:
f.write(tekst)
zet_teller("0\n")
status = ronde()
u.check("een teller van nul komt wel in de status",
status["tls"].get("open_connections") == 0)
u.check("nul verbindingen levert geen connect-regel op",
not [e for e in status["log"] if e.get("event") == "connect"])
zet_teller("1\n")
status = ronde()
connects = [e for e in status["log"] if e.get("event") == "connect"]
u.check("een verbinding erbij levert één connect-regel op",
len(connects) == 1, "regels: %r" % connects)
u.check("en die regel noemt hoeveel er nu openstaan",
connects and connects[0].get("note") == "1 open now",
"regel: %r" % (connects[0] if connects else None))
# Zelfde aantal, dus geen nieuwe regel. En de vorige moet blijven staan:
# dit is de toets die de vergeten opsomming vond.
status = ronde()
connects = [e for e in status["log"] if e.get("event") == "connect"]
u.check("een gelijk aantal levert geen tweede regel op",
len(connects) == 1, "regels: %r" % connects)
zet_teller("rommel")
status = ronde()
u.check("een onleesbare teller wordt niets, niet nul",
"open_connections" not in status["tls"],
"stond er: %r" % status["tls"])
u.check("en de eerdere connect-regel staat er nog",
len([e for e in status["log"] if e.get("event") == "connect"]) == 1)
# Van onbekend naar een aantal hoort ook een regel op te leveren. Dit is
# het geval "de wallet hing er al voordat de agent begon", en dat is
# precies wanneer iemand op de pagina komt kijken. Zonder deze regel blijft
# het log dan uren stil.
zet_teller("2\n")
status = ronde()
connects = [e for e in status["log"] if e.get("event") == "connect"]
u.check("na een onbekend aantal levert een verbinding alsnog een regel op",
len(connects) == 2, "regels: %r" % connects)
u.check("en die noemt het aantal dat er nu staat",
connects[-1].get("note") == "2 open now",
"regel: %r" % connects[-1])
def main():
u = Uitslag()
test_template_is_invulbaar_zonder_schade(u)
agent = load_agent()
test_einddatum_gelijk_aan_ssl(agent, u)
test_beide_tijdvormen(agent, u)
test_tijd_zonder_zulu_faalt_hard(agent, u)
test_certificaat_zonder_validity_faalt(agent, u)
test_live_certificaat_met_san(agent, u)
test_keuze_negeert_onbekende_id(agent, u)
test_keuze_zonder_geldig_certificaat(agent, u)
test_paren_vinden(agent, u)
# Deze twee als laatste, want ze verzetten de module-instellingen naar een
# tijdelijke map.
test_upload(agent, u)
test_sessieregels_indelen(agent, u)
test_open_verbindingen(agent, u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+434
View File
@@ -0,0 +1,434 @@
"""Toetst de zelfcontrole van de agent: antwoordt de eigen TLS-poort?
Waarom deze test bestaat: dit is de enige controle die over de app zelf gaat, en
hij kan op twee manieren stil verkeerd zijn. Hij kan "ok" melden terwijl er iets
anders aan de lijn hangt dan het gekozen certificaat, en hij kan het
activiteitenlog vullen met zijn eigen verbindingen. Dat tweede is geen schoonheids-
foutje: elke meting is voor nginx een gewone sessie, dus zonder rem en zonder
filter gaat het log over onszelf in plaats van over wallets.
De nadruk ligt daarom op wat er níet moet gebeuren: niet meten vlak na een
herlading, niet meten als er net gemeten is, en de eigen sessieregel niet laten
staan.
Draaien:
python tests/test_agent_zelfcontrole.py
Wat een echte TLS-server vraagt, vraagt openssl. Is dat er niet, dan slaan die
toetsen zichzelf over en zeggen dat erbij; de rest draait gewoon.
"""
import sys
# Zelfde reden als in test_agent_certificates.py: geen .pyc naast de app-map.
sys.dont_write_bytecode = True
import json # noqa: E402
import os # noqa: E402
import socket # noqa: E402
import ssl # noqa: E402
import tempfile # noqa: E402
import threading # noqa: E402
import time # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)
# De buurtest is hier de gereedschapskist: hij laadt agent.py.template als module
# en maakt echte sleutelparen met openssl. Dat tweede keer overdoen zou een tweede
# versie opleveren die uit elkaar loopt.
from test_agent_certificates import Uitslag, load_agent, openssl_paar # noqa: E402
class TlsServer:
"""Een echte TLS-server op een vrije poort van de loopback.
Echt en niet nagebootst, want wat hier getoetst wordt is een handdruk. Een
verzonnen server bewijst niets over `wrap_socket`.
"""
def __init__(self, cert, key):
self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.context.load_cert_chain(cert, key)
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
self.port = self.sock.getsockname()[1]
self.stoppen = False
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _loop(self):
while not self.stoppen:
try:
rauw, _ = self.sock.accept()
except OSError:
return
try:
with self.context.wrap_socket(rauw, server_side=True) as tls:
# Wachten tot de client sluit. De agent sluit meteen na de
# handdruk, dus dit levert b"" op en niet een hangende lus.
tls.recv(1)
except OSError:
pass
def close(self):
self.stoppen = True
try:
self.sock.close()
except OSError:
pass
class KaleServer:
"""Luistert wel, maar spreekt geen TLS. Accepteert en sluit meteen."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
self.port = self.sock.getsockname()[1]
self.stoppen = False
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _loop(self):
while not self.stoppen:
try:
rauw, _ = self.sock.accept()
except OSError:
return
rauw.close()
def close(self):
self.stoppen = True
try:
self.sock.close()
except OSError:
pass
def dichte_poort():
"""Een poortnummer waar zeker niets luistert."""
s = socket.socket()
s.bind(("127.0.0.1", 0))
poort = s.getsockname()[1]
s.close()
return poort
def richt(agent, poort):
agent.TLS_HOST = "127.0.0.1"
agent.TLS_PORT = poort
def test_zonder_certificaat_wordt_er_niet_verbonden(agent, u):
"""Geen keuze betekent geen poort 50022, en dus geen storing.
Dit is de normale toestand van een verse installatie. Hem als fout melden zou
de ergste soort onwaarheid zijn: precies daar staat al een foutmelding dat er
een certificaat gekozen moet worden, en een tweede rode melding erbij leidt
weg van de knop die het oplost.
"""
richt(agent, dichte_poort())
for leeg in (None, {}):
uitkomst = agent.probe_self(leeg)
u.check("zonder certificaat is de toestand 'off' (%r)" % (leeg,),
uitkomst.get("state") == "off", "gaf %r" % uitkomst)
u.check("en dan is er niets gemeten (%r)" % (leeg,),
"ms" not in uitkomst and "error" not in uitkomst,
"gaf %r" % uitkomst)
def test_niemand_luistert(agent, u):
"""Wel een certificaat, geen poort. Dat is wel een storing."""
richt(agent, dichte_poort())
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"})
u.check("een dichte poort geeft 'failed'",
uitkomst.get("state") == "failed", "gaf %r" % uitkomst)
u.check("en de reden staat erbij",
bool(uitkomst.get("error")), "gaf %r" % uitkomst)
u.check("een mislukking meldt geen meettijd",
"ms" not in uitkomst, "gaf %r" % uitkomst)
def test_luisteren_is_niet_genoeg(agent, u):
"""Iets dat luistert maar geen TLS spreekt, is ook een storing.
Dit onderscheidt de controle van een simpele poortcheck. Een open poort
bewijst niet dat er een bruikbaar certificaat achter zit, en juist dat is wat
een wallet nodig heeft.
"""
server = KaleServer()
try:
richt(agent, server.port)
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"})
u.check("luisteren zonder TLS geeft 'failed'",
uitkomst.get("state") == "failed", "gaf %r" % uitkomst)
finally:
server.close()
def test_gelukkig_pad_en_verkeerd_certificaat(agent, u):
"""De handdruk lukt, en dan gaat het erom wélk certificaat er terugkomt.
Het tweede geval is de reden dat deze controle niet op vertrouwen leunt maar
op de bytes van het certificaat: nginx dat na een mislukte herlading nog het
vorige certificaat serveert, is voor elke trust-controle in orde en is toch
precies de storing die je zoekt.
"""
with tempfile.TemporaryDirectory() as tmp:
paar = openssl_paar(tmp, "gekozen", "gate.example.org")
ander = openssl_paar(tmp, "ander", "andere.example.org")
if not paar or not ander:
print("OVERGESLAGEN: geen openssl, dus de handdruk is niet getoetst")
return
cert, key = paar
ander_cert, _ = ander
server = TlsServer(cert, key)
try:
richt(agent, server.port)
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": cert})
u.check("het gekozen certificaat aan de lijn geeft 'ok'",
uitkomst.get("state") == "ok", "gaf %r" % uitkomst)
u.check("en een meettijd in milliseconden",
isinstance(uitkomst.get("ms"), int) and uitkomst["ms"] >= 0,
"gaf %r" % uitkomst)
uitkomst = agent.probe_self({"domain": "andere.example.org",
"cert": ander_cert})
u.check("een ander certificaat aan de lijn wordt herkend",
uitkomst.get("state") == "wrong-certificate", "gaf %r" % uitkomst)
finally:
server.close()
def test_eigen_sessies_uit_het_log(agent, u):
"""De filter die het log over wallets laat gaan in plaats van over onszelf."""
def regel(t, event="probe", inn=0, uit=0):
return {"t": t, "event": event, "bytes_in": inn, "bytes_out": uit}
entries = [regel(1000), regel(2000)]
over = agent.suppress_self_probes(entries, [1001])
u.check("een eigen meting verdwijnt uit het log",
[e["t"] for e in over] == [2000], "over: %r" % over)
over = agent.suppress_self_probes([regel(1000)], [1010])
u.check("een scan buiten het venster blijft staan",
len(over) == 1, "over: %r" % over)
# Dit is de belangrijkste: een sessie waarin wél iets is doorgegeven, is per
# definitie niet van ons. Die weglaten zou een storing verbergen.
echt = regel(1000, event="refused", inn=120, uit=300)
over = agent.suppress_self_probes([echt], [1000])
u.check("een sessie met verkeer blijft staan, ook op hetzelfde moment",
over == [echt], "over: %r" % over)
# Eén onthouden meting laat hoogstens één regel weg. Anders zou een enkele
# meting een reeks echte scans kunnen opslokken.
over = agent.suppress_self_probes([regel(1000), regel(1001)], [1000])
u.check("één meting laat hoogstens één regel weg",
len(over) == 1, "over: %r" % over)
u.check("zonder metingen verandert er niets",
agent.suppress_self_probes([regel(1000)], []) == [regel(1000)])
def test_ronde_meet_niet_elke_keer(agent, u):
"""De rem, via `build_status`, dus via de ronde die de agent echt draait.
Drie dingen op een rij die alle drie stil fout kunnen gaan:
1. vlak na een certificaatwissel wordt er niet gemeten. nginx herlaadt binnen
tien seconden, dus meten zou het vorige certificaat zien en dat als storing
melden;
2. de ronde daarna wordt er wel gemeten;
3. de ronde daarna weer niet, want er is net gemeten. Zonder deze rem staat er
elke minuut een eigen regel in het activiteitenlog.
"""
with tempfile.TemporaryDirectory() as tmp:
bron = os.path.join(tmp, "certs")
os.makedirs(bron)
paar = openssl_paar(bron, "gate", "gate.example.org")
if not paar:
print("OVERGESLAGEN: geen openssl, dus de rem is niet getoetst")
return
cert, key = paar
agent.SOURCES = [("Own folder", bron)]
agent.ELECTRUM_HOST = "" # dan faalt de backend-probe zonder netwerk
agent.STATE_DIR = tmp
agent.CONFIG_DIR = os.path.join(tmp, "config")
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
agent.STATUS_FILE = os.path.join(tmp, "status.json")
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
agent.SELF_CHECK_INTERVAL = 300
server = TlsServer(cert, key)
try:
richt(agent, server.port)
def ronde():
status = agent.build_status([])
agent.write_status(status)
return status
eerste = ronde()["tls"]["self_check"]
u.check("vlak na de eerste keuze wordt er niet gemeten",
eerste.get("state") == "pending", "gaf %r" % eerste)
tweede = ronde()["tls"]["self_check"]
u.check("de ronde daarna wel, en die lukt",
tweede.get("state") == "ok", "gaf %r" % tweede)
derde = ronde()
u.check("en daarna niet opnieuw, want er is net gemeten",
derde["tls"]["self_check"].get("at") == tweede.get("at"),
"gaf %r" % derde["tls"]["self_check"])
u.check("er is dus precies één eigen meting onthouden",
len(derde.get("self_probes", [])) == 1,
"gaf %r" % derde.get("self_probes"))
# En nu het sluitstuk: de sessieregel die nginx van diezelfde meting
# geschreven zou hebben, hoort niet in het log te komen. Een tweede
# regel even ervoor is niet van ons en moet blijven staan.
gemeten = tweede["at"]
with open(agent.STREAM_LOG, "w", encoding="utf-8") as f:
for t in (gemeten - 60, gemeten):
f.write("%s 500 0 0 0.010\n"
% time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(t)))
log = ronde()["log"]
momenten = [e["t"] for e in log if e.get("event") == "probe"]
u.check("de eigen sessieregel staat niet in het log",
gemeten not in momenten, "regels op: %r" % momenten)
u.check("en de scan van iemand anders wel",
(gemeten - 60) in momenten, "regels op: %r" % momenten)
finally:
server.close()
def test_eerste_ronde_en_snelle_herhaling(agent, u):
"""De startrace, en de rem die een mislukking vasthield.
AANLEIDING (gemeld door de gebruiker, 31-08-2026): na elke herstart of update
las de statuskaart vijf minuten lang "Not answering" met
`[Errno 111] Connection refused`, terwijl de gateway van buiten gewoon werkte.
De oorzaak was een startvolgorde en geen storing. De compose laat `server` ná
de agent starten (`depends_on: [agent]`), en `build_status` draait meteen bij
het opstarten. De allereerste meting valt dus gegarandeerd in het gat waarin
nginx nog niet luistert. Dat was op zichzelf te verdragen; het probleem was dat
de volgende meting pas na SELF_CHECK_INTERVAL kwam.
Twee dingen worden hier vastgelegd, en ze repareren allebei een helft:
1. de eerste ronde van een proces meet niet, ook niet als het certificaat
ongewijzigd is (dus met `reloaded` op False, en dát is het geval dat de
bestaande toets hierboven NIET raakt: daar is de eerste ronde `pending`
omdat het certificaat net gekozen is);
2. na een mislukking wordt er na SELF_CHECK_RETRY opnieuw gemeten en niet pas
na SELF_CHECK_INTERVAL.
"""
with tempfile.TemporaryDirectory() as tmp:
bron = os.path.join(tmp, "certs")
os.makedirs(bron)
paar = openssl_paar(bron, "gate", "gate.example.org")
if not paar:
print("OVERGESLAGEN: geen openssl, dus de startrace is niet getoetst")
return
cert, key = paar
agent.SOURCES = [("Own folder", bron)]
agent.ELECTRUM_HOST = ""
agent.STATE_DIR = tmp
agent.CONFIG_DIR = os.path.join(tmp, "config")
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
agent.STATUS_FILE = os.path.join(tmp, "status.json")
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
agent.SELF_CHECK_INTERVAL = 300
agent.SELF_CHECK_RETRY = 60
server = TlsServer(cert, key)
try:
richt(agent, server.port)
def ronde():
status = agent.build_status([])
agent.write_status(status)
return status
ronde() # certificaat gekozen: pending via reloaded
tweede = ronde()["tls"]["self_check"]
u.check("de meting lukt zodra het certificaat staat",
tweede.get("state") == "ok", "gaf %r" % tweede)
# Nu doen alsof het proces opnieuw begint, met een ONGEWIJZIGD
# certificaat. Vóór de reparatie mat hij hier gewoon door.
agent.EERSTE_RONDE = True
herstart = ronde()["tls"]["self_check"]
u.check("de eerste ronde na een herstart meet niet",
herstart.get("state") == "pending", "gaf %r" % herstart)
u.check("en zonder `at`, zodat de volgende ronde wél meet",
"at" not in herstart, "gaf %r" % herstart)
derde = ronde()["tls"]["self_check"]
u.check("de ronde daarna meet wel",
derde.get("state") == "ok", "gaf %r" % derde)
# De tweede helft: een mislukking mag niet vijf minuten blijven staan.
# De opgeslagen meting met de hand op 'failed' zetten, 90 seconden oud:
# dat is meer dan SELF_CHECK_RETRY en minder dan SELF_CHECK_INTERVAL.
def zet_vorige(state, ouderdom):
with open(agent.STATUS_FILE, "r", encoding="utf-8") as f:
status = json.load(f)
status["tls"]["self_check"] = {
"at": int(time.time()) - ouderdom, "state": state,
}
with open(agent.STATUS_FILE, "w", encoding="utf-8") as f:
json.dump(status, f)
zet_vorige("failed", 90)
na_fout = ronde()["tls"]["self_check"]
u.check("na een mislukking wordt er binnen een minuut opnieuw gemeten",
na_fout.get("state") == "ok", "gaf %r" % na_fout)
# En de tegenproef, want anders toetst het bovenstaande alleen dat er
# ooit gemeten wordt: een GESLAAGDE meting van dezelfde ouderdom hoort
# juist te blijven staan.
zet_vorige("ok", 90)
na_goed = ronde()["tls"]["self_check"]
u.check("een geslaagde meting van 90 seconden oud blijft staan",
na_goed.get("at") == int(time.time()) - 90,
"gaf %r" % na_goed)
finally:
server.close()
def main():
u = Uitslag()
agent = load_agent()
test_zonder_certificaat_wordt_er_niet_verbonden(agent, u)
test_niemand_luistert(agent, u)
test_luisteren_is_niet_genoeg(agent, u)
test_gelukkig_pad_en_verkeerd_certificaat(agent, u)
test_eigen_sessies_uit_het_log(agent, u)
# Als laatste, want deze verzet de module-instellingen naar een tijdelijke map.
test_ronde_meet_niet_elke_keer(agent, u)
test_eerste_ronde_en_snelle_herhaling(agent, u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+546
View File
@@ -0,0 +1,546 @@
"""Toetst de vorm die umbrelOS van deze app store eist, voor élke app erin.
Waarom deze test bestaat. De twee bestaande testbestanden gaan over Electrum
Gate; ze noemen die app-map bij naam. Sinds 25-08-2026 zit er een tweede app in
deze repo en zijn er dus regels die niet over één app gaan maar over alle:
- het `id` in `umbrel-app.yml` moet gelijk zijn aan de mapnaam;
- dat id moet beginnen met het store-id uit `umbrel-app-store.yml`;
- de manifestvelden staan in de volgorde die de packaging-documentatie
voorschrijft;
- er is een `docker-compose.yml`, en de `app_proxy` daarin wijst naar een
service die in datzelfde bestand bestaat.
Dat zijn precies de fouten die je op het apparaat pas merkt: de app verschijnt
niet in de store, of hij verschijnt en start niet. Er komt geen nette
foutmelding, want de runtime-validatie van het manifest staat in umbreld
uitgecommentarieerd.
Wat deze toetsen expres NIET doen: eisen dat elke image op een digest gepind is.
Dat is wél de regel, maar op 25-08-2026 haalt geen van de twee apps hem, en een
suite die altijd rood staat wordt niet gelezen. Het staat als taak in de plannen
Umbrelapp en Publicatie-Gate. Wat hier wel gebeurt is de pinstatus afdrukken, zodat
je hem ziet zonder erover te struikelen. Wat wél getoetst wordt, sinds 07-09-2026:
dat de tag van een eigen image gelijk is aan VERSION in het bijbehorende
tools/<app>/build.sh, want een tag die nooit gebouwd is faalt pas op het apparaat.
Deze toetsen vinden hun apps zelf. Komt er een derde app bij, dan valt die
automatisch onder alles hierboven en hoeft hier niets bij.
Draaien:
python tests/test_appstore_vorm.py
"""
import sys
sys.dont_write_bytecode = True
import os # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
STORE = os.path.join(REPO, "umbrel-app-store.yml")
# De volgorde uit de packaging-documentatie. Wat de spec niet noemt (icon,
# backupIgnore, submitter, submission) mag erachter, niet ertussen.
VOORGESCHREVEN = [
"manifestVersion", "id", "category", "name", "version", "tagline",
"description", "releaseNotes", "developer", "website", "dependencies",
"repo", "support", "port", "gallery", "path",
]
# Velden die er per se moeten staan, ook al is het schema soepeler: zonder deze
# is de winkelpagina leeg of start de app niet.
VERPLICHT = ["manifestVersion", "id", "category", "name", "version", "tagline",
"description", "port"]
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def lees(pad):
with open(pad, "r", encoding="utf-8") as f:
return f.read()
def veld(tekst, naam):
"""De waarde van een veld op het eerste niveau, of None.
Geen YAML-lezer: de suite heeft geen afhankelijkheden en dat is een
projectregel. Daarom alleen velden die aan het begin van een regel staan.
"""
for regel in tekst.splitlines():
if regel.startswith(naam + ":"):
return regel.split(":", 1)[1].strip().strip('"').strip("'")
return None
def velden_in_volgorde(tekst):
"""De veldnamen op het eerste niveau, in de volgorde waarin ze staan."""
namen = []
for regel in tekst.splitlines():
if not regel or regel[0] in " \t#-":
continue
if ":" not in regel:
continue
namen.append(regel.split(":", 1)[0].strip())
return namen
def app_mappen():
"""Elke map in de repo-root met een umbrel-app.yml erin."""
gevonden = []
for naam in sorted(os.listdir(REPO)):
pad = os.path.join(REPO, naam)
if not os.path.isdir(pad):
continue
if os.path.isfile(os.path.join(pad, "umbrel-app.yml")):
gevonden.append(naam)
return gevonden
def test_er_is_een_store_met_apps(u, store_id, apps):
u.check("umbrel-app-store.yml heeft een id", bool(store_id))
u.check("en er is minstens een app-map gevonden", bool(apps),
"geen enkele map met een umbrel-app.yml")
def test_id_en_mapnaam(u, store_id, app):
"""Mapnaam == id == store-prefix + rest. Alle drie of de app bestaat niet."""
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
app_id = veld(tekst, "id")
u.check("%s: het manifest heeft een id" % app, bool(app_id))
if not app_id:
return
u.check("%s: id is gelijk aan de mapnaam" % app, app_id == app,
"manifest zegt %r" % app_id)
u.check("%s: id begint met het store-id %r" % (app, store_id),
app_id.startswith(store_id + "-"),
"id is %r" % app_id)
u.check("%s: id is lowercase kebab-case" % app,
app_id == app_id.lower() and " " not in app_id and "_" not in app_id,
"id is %r" % app_id)
def test_verplichte_velden(u, app):
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
ontbreekt = [naam for naam in VERPLICHT if veld(tekst, naam) is None]
u.check("%s: alle verplichte manifestvelden staan erin" % app,
not ontbreekt, "ontbreekt: %r" % ontbreekt)
def test_manifest_volgorde(u, app):
"""De voorgeschreven velden in de voorgeschreven onderlinge orde.
Niet dat ze er alle zestien zijn: `dependencies` hoort weg te blijven bij een
app zonder afhankelijkheden. Wel dat wat er staat niet door elkaar loopt, want
dat is de eis waar een review op valt.
"""
aanwezig = velden_in_volgorde(lees(os.path.join(REPO, app, "umbrel-app.yml")))
volgens_spec = [n for n in aanwezig if n in VOORGESCHREVEN]
verwacht = [n for n in VOORGESCHREVEN if n in volgens_spec]
u.check("%s: de manifestvelden staan in de voorgeschreven volgorde" % app,
volgens_spec == verwacht,
"gevonden %r, verwacht %r" % (volgens_spec, verwacht))
# Wat de spec niet noemt hoort erachter, niet ertussen. Anders is de kop niet
# letterlijk goed en moet er bij inlevering geschoven worden.
laatste_spec = -1
for i, naam in enumerate(aanwezig):
if naam in VOORGESCHREVEN:
laatste_spec = i
extra_ertussen = [n for n in aanwezig[:laatste_spec] if n not in VOORGESCHREVEN]
u.check("%s: velden buiten de spec staan achteraan" % app,
not extra_ertussen, "ertussen: %r" % extra_ertussen)
def recept_map(app):
"""Waar het bouwrecept van een app staat: tools/<app-id zonder store-id>/.
Dat is de afspraak uit Werkafspraken.md: een Dockerfile hoort niet in de app-map,
want die staat niet in de update-whitelist. De mapnaam is het app-id zonder
het store-voorvoegsel, dus whatsnext-evolu-relay bouwt in tools/evolu-relay/.
"""
kort = app.split("-", 1)[1] if "-" in app else app
return os.path.join(REPO, "tools", kort)
def test_image_tag_is_de_gebouwde(u, app):
"""De tag in de compose is VERSION uit build.sh, voor elke eigen image.
Een tag die in de compose staat maar nooit gebouwd is, faalt pas op het
apparaat met "pull access denied". En andersom, een VERSION die verhoogd is
zonder de compose mee te nemen, levert een nieuwe image en een oude
installatie zonder dat iets dat meldt. Beide staan als regel in Werkafspraken.md;
dit is de toets erop.
Alleen images uit het eigen recept: een vreemde image (postgres, nginx) heeft
geen build.sh en valt hier buiten.
"""
build = os.path.join(recept_map(app), "build.sh")
if not os.path.isfile(build):
return
versie = None
for regel in lees(build).splitlines():
if regel.startswith("VERSION="):
versie = regel.split("=", 1)[1].strip().strip('"')
u.check("%s: build.sh heeft een VERSION" % app, bool(versie))
if not versie:
return
kort = os.path.basename(recept_map(app))
compose = lees(os.path.join(REPO, app, "docker-compose.yml"))
eigen = []
for regel in compose.splitlines():
kaal = regel.strip()
if not kaal.startswith("image:"):
continue
image = kaal.split(":", 1)[1].strip()
repo_en_tag = image.split("@", 1)[0]
repo_naam = repo_en_tag.rsplit(":", 1)[0]
if repo_naam.endswith("/" + kort):
eigen.append(repo_en_tag)
u.check("%s: de compose gebruikt de eigen image" % app, bool(eigen),
"geen image op .../%s gevonden" % kort)
verkeerd = [i for i in eigen if not i.endswith(":" + versie)]
u.check("%s: elke eigen image heeft tag %s uit build.sh" % (app, versie),
not verkeerd, "gevonden: %r" % verkeerd)
def test_compose_bestaat_en_hangt_samen(u, app):
"""De app_proxy moet naar een service wijzen die bestaat.
Dit is een echte klasse fouten en niet een formaliteit: de hostnaam is
`<app-id>_<service>_1`, dus hij bevat het app-id én de servicenaam. Verandert
een van de twee, dan wijst de proxy naar niets en meldt umbrelOS alleen dat de
server onbereikbaar is. Precies het symptoom dat bij Electrum Gate 0.0.3 een
dag kostte.
"""
pad = os.path.join(REPO, app, "docker-compose.yml")
u.check("%s: er is een docker-compose.yml" % app, os.path.isfile(pad))
if not os.path.isfile(pad):
return
tekst = lees(pad)
# Servicenamen: twee spaties diep onder services:, eindigend op een dubbele
# punt. Geen YAML-lezer, zie veld().
services = []
in_services = False
for regel in tekst.splitlines():
if regel.startswith("services:"):
in_services = True
continue
if in_services and regel and not regel[0].isspace():
break
if in_services and regel.startswith(" ") and not regel.startswith(" "):
kaal = regel.strip()
if kaal.endswith(":") and not kaal.startswith("#"):
services.append(kaal[:-1])
u.check("%s: de compose heeft een app_proxy" % app,
"app_proxy" in services, "services: %r" % services)
app_host = None
for regel in tekst.splitlines():
if regel.strip().startswith("APP_HOST:"):
app_host = regel.split(":", 1)[1].strip()
break
u.check("%s: de app_proxy heeft een APP_HOST" % app, bool(app_host))
if not app_host:
return
u.check("%s: APP_HOST begint met het app-id" % app,
app_host.startswith(app + "_"),
"APP_HOST is %r maar de map heet %r" % (app_host, app))
# De vorm is <app-id>_<service>_1; haal de servicenaam eruit en kijk of die
# bestaat.
rest = app_host[len(app) + 1:]
u.check("%s: APP_HOST eindigt op _1" % app, rest.endswith("_1"),
"APP_HOST is %r" % app_host)
service = rest[:-2] if rest.endswith("_1") else rest
u.check("%s: APP_HOST wijst naar een service die bestaat" % app,
service in services,
"wijst naar %r, aanwezig: %r" % (service, services))
def test_data_onder_data(u, app):
"""Wat de app bewaart staat onder data/, en er is een .gitkeep per map.
De appstore-eis is dat gebruikersstaat onder ${APP_DATA_DIR}/data/... valt en
dat elke map die bij de eerste start moet bestaan in de repo staat. Een mount
naar een map die er niet is, maakt Docker aan als root, en dan kan de app er
niet in schrijven.
"""
pad = os.path.join(REPO, app, "docker-compose.yml")
if not os.path.isfile(pad):
return
tekst = lees(pad)
buiten = []
ontbreekt = []
for regel in tekst.splitlines():
kaal = regel.strip()
if not kaal.startswith("- ${APP_DATA_DIR}/"):
continue
pad_in_app = kaal[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]
# Losse bestanden die uit een *.template komen zijn geen gebruikersstaat.
if "/" not in pad_in_app:
continue
if not pad_in_app.startswith("data/"):
buiten.append(pad_in_app)
continue
op_schijf = os.path.join(REPO, app, pad_in_app)
if not os.path.exists(op_schijf):
ontbreekt.append(pad_in_app)
elif os.path.isdir(op_schijf) and not os.path.isfile(
os.path.join(op_schijf, ".gitkeep")):
ontbreekt.append(pad_in_app + "/.gitkeep")
u.check("%s: alle mounts met een pad staan onder data/" % app,
not buiten, "erbuiten: %r" % buiten)
u.check("%s: elke gemounte map bestaat in de repo" % app,
not ontbreekt, "ontbreekt: %r" % ontbreekt)
def test_containernamen_zijn_volledig(u, app):
"""Verwijzingen tussen containers gebruiken de volledige naam.
Alle apps van umbrelOS delen één Docker-netwerk, dus een servicenaam als
'agent' of 'server' is daar niet uniek. Heeft een tweede app dezelfde naam,
dan verdeelt Docker de naam over beide containers en komt ongeveer de helft
van het verkeer bij de verkeerde app uit.
Dat is geen theorie: op 28-08-2026 gaf de statuspagina van Evolu Relay
afwisselend goede gegevens en een 404, omdat Electrum Gate óók een 'agent' op
poort 8000 heeft. Van tien verzoeken kwamen er vijf verkeerd uit. Het maakte
bovendien de pagina van Electrum Gate kapot, een app die tot dat moment werkte.
De vorm <app-id>_<service>_1 is wél uniek. Deze toets kijkt naar de plekken
waar een naam als hostnaam wordt gebruikt: proxy_pass in een nginx-config, en
de omgevingsvariabelen die een hostnaam dragen. Een nginx-config kan in de
app-map staan (als *.template) of in het bouwrecept onder tools/ (als hij in
een eigen image zit); beide worden gelezen.
"""
fout = []
bestanden = []
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
if bestandsnaam.endswith(".template") or bestandsnaam == "docker-compose.yml":
bestanden.append(os.path.join(REPO, app, bestandsnaam))
recept = recept_map(app)
if os.path.isdir(recept):
for bestandsnaam in sorted(os.listdir(recept)):
if bestandsnaam.endswith(".conf"):
bestanden.append(os.path.join(recept, bestandsnaam))
for pad in bestanden:
bestandsnaam = os.path.relpath(pad, REPO).replace(os.sep, "/")
tekst = lees(pad)
for regel in tekst.splitlines():
kaal = regel.strip()
if kaal.startswith("#"):
continue
naam = None
if kaal.startswith("proxy_pass http://"):
naam = kaal[len("proxy_pass http://"):].split(":", 1)[0].split("/", 1)[0]
elif "_HOST:" in kaal and not kaal.startswith("- "):
waarde = kaal.split(":", 1)[1].strip().strip('"')
# Een variabele van umbrelOS of een IP-adres is geen containernaam.
if waarde and not waarde.startswith("${") and not waarde[0].isdigit():
naam = waarde
if naam is None or naam in ("localhost", "127.0.0.1"):
continue
if not naam.startswith(app + "_"):
fout.append("%s: %s" % (bestandsnaam, naam))
u.check("%s: verwijzingen naar containers gebruiken de volledige naam" % app,
not fout, "korte namen: %r" % fout)
def test_geen_werkbestanden_in_de_app_map(u, app):
"""In een app-map staat alleen wat umbreld nodig heeft.
umbreld kopieert bij een installatie de héle app-map naar
`~/umbrel/app-data/<app-id>/` met `rsync --archive`. Alles wat daar staat
belandt dus op het apparaat en in de back-up: een CLAUDE.md, een gelaagd
bewerkbestand van een icoon, een testscript, een aantekening.
Dat is geen theorie. Op 30-08-2026 stond er een `icon.pdn` van 175 kB in de map
van Evolu Relay, een Paint.NET-bestand van een icoon dat nog niet af was. Het
apparaat kan er niets mee en het maakt elke back-up groter. Verplaatst naar
`tools/icons/`; deze toets is wat voorkomt dat de volgende terugkomt.
De lijst hieronder is een whitelist en geen blacklist, en dat is met opzet: bij
een nieuw soort bestand hoort iemand na te denken of het daar hoort, en een
blacklist stelt die vraag nooit.
"""
TOEGESTAAN_EXACT = {
"umbrel-app.yml", "docker-compose.yml", "exports.sh", "torrc",
"icon.png", "icon.svg", ".gitkeep",
}
TOEGESTAANE_MAPPEN = {"data", "hooks"}
fout = []
for naam in sorted(os.listdir(os.path.join(REPO, app))):
pad = os.path.join(REPO, app, naam)
if os.path.isdir(pad):
# __pycache__ is niet gecommit (het staat in .gitignore) en komt dus
# nooit in de kloon die umbreld ophaalt. Het staat er in een werkboom
# zodra een test de agent-template importeert, en daarover klagen zou
# de toets rood zetten op iets dat het apparaat niet bereikt.
if naam == "__pycache__":
continue
if naam not in TOEGESTAANE_MAPPEN:
fout.append(naam + "/")
continue
if naam in TOEGESTAAN_EXACT:
continue
# Alles wat umbreld bij een update ververst is een template, en die horen
# er dus per definitie.
if naam.endswith(".template"):
continue
fout.append(naam)
u.check("%s: er staan geen werkbestanden in de app-map" % app,
not fout,
"deze horen buiten de app-map: %r" % fout)
# De variabelen die umbrelOS werkelijk invult. Alleen deze mogen in een template
# staan; zie test_templates_hebben_geen_losse_dollars. Overgenomen uit de tabel in
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md §3.
UMBREL_VARIABELEN = {
"APP_ID", "APP_VERSION", "APP_DATA_DIR", "APP_MANIFEST_FILE", "UMBREL_ROOT",
"DEVICE_HOSTNAME", "DEVICE_DOMAIN_NAME", "APP_DOMAIN",
"APP_PROXY_HOSTNAME", "APP_PROXY_PORT", "NETWORK_IP",
"TOR_PROXY_IP", "TOR_PROXY_PORT", "TOR_DATA_DIR",
"APP_HIDDEN_SERVICE", "APP_SEED", "APP_PASSWORD",
}
# Een app krijgt daarnaast de exports van zijn afhankelijkheden, en die heten
# APP_<AFHANKELIJKHEID>_<IETS>. Electrum Gate gebruikt APP_ELECTRS_NODE_IP en
# APP_ELECTRS_NODE_PORT. Die kunnen niet in een vaste lijst staan, want welke er
# zijn hangt af van de afhankelijkheid; vandaar een vorm in plaats van een naam.
EXPORT_VORM = r"^APP_[A-Z0-9]+_[A-Z0-9_]+$"
def test_templates_hebben_geen_losse_dollars(u, app):
"""In een *.template staat geen dollarteken dat umbrelOS niet invult.
Dit is de valstrik van dit hele project, en tot 30-08-2026 beweerde het
commentaar in drie bestanden dat déze test hem dichthield terwijl dat niet zo
was.
Wat er gebeurt: umbreld haalt elke `*.template` bij het starten door envsubst.
Dat vervangt élke accolade-vorm, ook een variabele die niet bestaat, en die
wordt dan leeg. Gevolgen per bestandstype:
- in een pagina sloopt het een JavaScript-template-literal, want die gebruikt
accolades achter een dollarteken. Vandaar dat de pagina's overal strings met
een plus aan elkaar plakken;
- in een nginx-config verdwijnen `host`, `log_format` en elke variabele in een
access_log;
- in Python-code verdwijnt stilzwijgend een stuk code.
Er komt geen foutmelding. Het bestand wordt gewoon anders dan je schreef.
Beide vormen die envsubst kent worden gevlagd: `${NAAM}` en het kale `$NAAM`.
Dat tweede is de vorm waarin een nginx-variabele als `$host` geschreven wordt,
en die is hier dus net zo fout als de eerste.
Wat NIET gevlagd wordt is een los dollarteken zonder naam erachter. Dat laat
envsubst staan, en het staat in dit project in de commentaarregels die deze
regel juist uitleggen: een toets die zijn eigen uitleg rood zet, wordt
uitgezet.
"""
import re
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
if not bestandsnaam.endswith(".template"):
continue
tekst = lees(os.path.join(REPO, app, bestandsnaam))
onbekend = []
for regelnummer, regel in enumerate(tekst.splitlines(), start=1):
for treffer in re.finditer(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", regel):
naam = treffer.group(1)
if naam in UMBREL_VARIABELEN:
continue
if re.match(EXPORT_VORM, naam):
continue
onbekend.append("regel %d: %r" % (regelnummer, treffer.group(0)))
u.check("%s/%s: geen dollartekens die umbrelOS leegmaakt" % (app, bestandsnaam),
not onbekend,
"gevonden: %r" % onbekend[:4])
def rapporteer_pinstatus(apps):
"""Afdrukken, niet toetsen. Zie de uitleg bovenaan dit bestand."""
print()
print("Pinstatus van de images (informatief, geen toets):")
for app in apps:
pad = os.path.join(REPO, app, "docker-compose.yml")
if not os.path.isfile(pad):
continue
for regel in lees(pad).splitlines():
kaal = regel.strip()
if not kaal.startswith("image:"):
continue
image = kaal.split(":", 1)[1].strip()
merk = "gepind" if "@sha256:" in image else "NIET GEPIND"
print(" %-24s %-12s %s" % (app, merk, image))
def main():
u = Uitslag()
store_id = veld(lees(STORE), "id")
apps = app_mappen()
test_er_is_een_store_met_apps(u, store_id, apps)
for app in apps:
test_id_en_mapnaam(u, store_id, app)
test_verplichte_velden(u, app)
test_manifest_volgorde(u, app)
test_compose_bestaat_en_hangt_samen(u, app)
test_image_tag_is_de_gebouwde(u, app)
test_data_onder_data(u, app)
test_containernamen_zijn_volledig(u, app)
test_geen_werkbestanden_in_de_app_map(u, app)
test_templates_hebben_geen_losse_dollars(u, app)
rapporteer_pinstatus(apps)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+201
View File
@@ -0,0 +1,201 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst de testcliënt zonder relay: tools/relay-client/src/client.js
//
// Draaien: node tests/test_client_lokaal.mjs
//
// Anders dan de rest van deze map heeft deze toets wél afhankelijkheden nodig,
// namelijk die van tools/relay-client. Staat `node_modules` daar niet, dan slaat
// hij zichzelf over en meldt dat, zoals de certificaattoetsen doen als de
// certificaatwinkel van het besturingssysteem ontbreekt. Installeren gaat met
// `npm install` in tools/relay-client.
//
// Waarom dit bestand er is: `@evolu/nodejs` bevat geen cliënt, dus de
// afhankelijkheden voor `createEvolu` worden in src/evolu-node.js zelf
// samengesteld. Dat is de plek die bij een versieverhoging van Evolu als eerste
// omvalt, en hij faalt stil: alle vier de fouten die dat bestand tijdens de bouw
// opleverde gaven een instantie die het lijkt te doen, tot de eerste
// schrijfactie. **Deze toets doet dus een schrijfactie**, en aanmaken alleen is
// geen bewijs.
//
// Geen relay: `openStore` zonder `relayUrl`. Wat hier getoetst wordt is de
// cliënt, en die is los van het netwerk te beproeven. Het toegangsbeleid van de
// relay staat in test_limiter.mjs.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Een wachthond, en die is hier niet voor de sier. Gaat er iets mis in de
// samenstelling, dan komt dat er meestal uit als een schrijfactie die nooit
// antwoordt in plaats van een fout: het defect zit in een worker en de
// `loadQuery` erna blijft hangen. Zonder deze wachthond blijft de suite dus
// staan in plaats van rood te worden, en dat is bij de mutatietoets van
// 09-09-2026 letterlijk gebeurd.
const WACHTHOND_SECONDEN = 30;
setTimeout(() => {
console.log(`FOUT: niets meer gehoord na ${WACHTHOND_SECONDEN} seconden.`);
console.log(' Dat wijst op een defect in een worker; zie tools/relay-client/src/evolu-node.js.');
console.log('');
console.log('afgebroken');
process.exit(1);
}, WACHTHOND_SECONDEN * 1000).unref();
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
// ── Zijn de afhankelijkheden er? ──────────────────────────────────────────────
let client;
try {
client = await import('../tools/relay-client/src/client.js');
} catch (oorzaak) {
console.log('OVERGESLAGEN: de pakketten van tools/relay-client ontbreken.');
console.log(` Draai daar eerst \`npm install\`. (${oorzaak.message})`);
console.log('');
console.log('0 goed, 0 fout');
process.exit(0);
}
const { eigenaarUitMnemonic, nieuweEigenaar, openStore } = client;
// ── Een mnemonic levert altijd hetzelfde OwnerId op ───────────────────────────
{
// Dit is de toets die omvalt als Evolu onder ons iets anders gaat doen met de
// afleiding van een eigenaar. Vaste mnemonic, vast antwoord. Het is geen
// geheim: deze eigenaar bestaat alleen in deze toets, en het is bovendien de
// bekendste testmnemonic die er is.
const MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const eerste = eigenaarUitMnemonic(MNEMONIC);
const tweede = eigenaarUitMnemonic(MNEMONIC);
toets('dezelfde mnemonic geeft hetzelfde OwnerId', eerste.id === tweede.id);
toets('en een OwnerId is een niet-lege string', typeof eerste.id === 'string' && eerste.id.length > 0);
toets('de eigenaar houdt zijn mnemonic bij zich', eerste.mnemonic === MNEMONIC);
toets('een verse eigenaar krijgt een ander OwnerId', nieuweEigenaar().id !== eerste.id);
let geweigerd = false;
try {
eigenaarUitMnemonic('dit is geen mnemonic');
} catch {
geweigerd = true;
}
// Het niet-gelukkige pad, en dat is hier het belangrijkste: stil een andere
// eigenaar maken zou een proef opleveren die nergens over gaat.
toets('een onleesbare mnemonic wordt geweigerd', geweigerd);
}
// ── Schrijven en teruglezen, zonder relay ─────────────────────────────────────
const map = mkdtempSync(join(tmpdir(), 'relay-client-toets-'));
let store = null;
try {
const defecten = [];
// Vastgehouden, want verderop wordt met dezelfde eigenaar heropend.
const eigenaar = nieuweEigenaar();
store = await openStore({
directory: map,
owner: eigenaar,
onDefect: (defect) => {
defecten.push(defect);
},
});
toets('zonder relayUrl is er geen transport', store.transportUrl === null);
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
const geschreven = await store.schrijf('toets', bytes);
toets('een blob wegschrijven geeft een id terug', typeof geschreven?.id === 'string');
const rijen = await store.lees();
toets('en die blob komt er weer uit', rijen.length === 1);
toets('met hetzelfde label', rijen[0]?.label === 'toets');
toets(
'en met de bytes ongeschonden',
rijen[0]?.body?.length === bytes.length
&& [...(rijen[0]?.body ?? [])].every((waarde, plek) => waarde === bytes[plek]),
);
const sluiten = store.sluit;
store = null;
await sluiten();
// Opnieuw openen met dezelfde eigenaar en dezelfde map. Dit is de toets die
// een echte fout van 09-09-2026 vasthoudt: `evolu.insert` zet de schrijfactie
// in de wachtrij van de database-worker en geeft meteen een id terug, dus wie
// vlak daarna afsluit is de rij kwijt. Het symptoom was een `lees` die "nog
// geen blobs" meldde over een blob die zojuist bevestigd was, en dát is precies
// het soort fout dat een toets binnen één instantie niet vindt.
store = await openStore({ directory: map, owner: eigenaar });
const naHeropenen = await store.lees();
toets('de blob overleeft het afsluiten en heropenen', naHeropenen.length === 1);
toets('met hetzelfde label', naHeropenen[0]?.label === 'toets');
const nogmaalsSluiten = store.sluit;
store = null;
await nogmaalsSluiten();
// ── Twee eigenaars tegelijk ────────────────────────────────────────────────
//
// Dit is de toets voor de fout die de gebruiker op 09-09-2026 vond, en het is
// een fout die met één eigenaar onzichtbaar is. `createEvoluDeps` vraagt het
// slot "tab" aan en kondigt de houder aan als tab leader; dat slot kan er maar
// één hebben. Werd dat per eigenaar aangeroepen, dan werd de tweede nooit
// leider en brak zijn database-worker af met "Expected tab leader port.".
const eersteVanTwee = await openStore({
directory: map,
owner: nieuweEigenaar(),
onDefect: (defect) => defecten.push(defect),
});
const tweedeVanTwee = await openStore({
directory: map,
owner: nieuweEigenaar(),
onDefect: (defect) => defecten.push(defect),
});
try {
await eersteVanTwee.schrijf('van de eerste', new Uint8Array([1]));
await tweedeVanTwee.schrijf('van de tweede', new Uint8Array([2, 2]));
const vanEerste = await eersteVanTwee.lees();
const vanTweede = await tweedeVanTwee.lees();
toets('twee eigenaars kunnen tegelijk schrijven', vanEerste.length === 1 && vanTweede.length === 1);
// En ze zien elkaars gegevens niet, want dat is de hele belofte van een
// eigenaar. Zou dat wél zo zijn, dan deelden ze een database.
toets('en ze zien elkaars blobs niet', vanEerste[0]?.label === 'van de eerste' && vanTweede[0]?.label === 'van de tweede');
// De eerste sluiten mag de tweede niet slopen: de afhankelijkheden zijn
// gedeeld, dus ze gaan pas weg als de laatste store dicht is.
await eersteVanTwee.sluit();
const naSluiten = await tweedeVanTwee.lees();
toets('de tweede blijft werken als de eerste sluit', naSluiten.length === 1);
} finally {
await tweedeVanTwee.sluit();
}
// Afsluiten hoort stil te zijn. Ging dat mis, dan bleef er een worker draaien
// die zijn eigen run al kwijt was, en dat merk je pas als een proces blijft
// hangen. Dat was de vierde valstrik uit PLAN.md §4e.
toets(`afsluiten levert geen defect op (${defecten.length} gezien)`, defecten.length === 0);
} finally {
if (store) await store.sluit();
rmSync(map, { recursive: true, force: true });
}
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+243
View File
@@ -0,0 +1,243 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het register, de opdrachtregel en de instellingen van de testclient.
//
// Draaien: node tests/test_client_register.mjs
//
// Géén afhankelijkheden nodig, anders dan test_client_lokaal.mjs: register.js,
// argumenten.js en config.js raken Evolu niet aan. Dat is met opzet zo gebouwd
// en het is dezelfde scheiding die policy.js aan de relay-kant heeft: alles wat
// te toetsen valt zonder netwerk, staat los van alles wat dat niet is.
//
// Waar dit op let: het register bevat mnemonics. De duurste fout zit hier niet
// in een berekening maar in stil overschrijven of stil vervangen, en dat is dus
// wat hieronder het meeste aandacht krijgt.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isBruikbareNaam, normaliseer, openRegister } from '../tools/relay-client/src/register.js';
import { leesArgumenten, OPDRACHTEN } from '../tools/relay-client/src/argumenten.js';
import { leesConfig, leesEnvBestand, naarRelayUrl } from '../tools/relay-client/src/config.js';
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
const gooit = (aan) => {
try {
aan();
return false;
} catch {
return true;
}
};
const verseMap = () => mkdtempSync(join(tmpdir(), 'relay-client-register-'));
const mappen = [];
const map = () => {
const nieuw = verseMap();
mappen.push(nieuw);
return nieuw;
};
// ── Namen ─────────────────────────────────────────────────────────────────────
{
for (const naam of ['a', 'proef-1', 'trezor_mac', 'A'.repeat(40)]) {
toets(`"${naam}" mag als naam`, isBruikbareNaam(naam));
}
for (const naam of ['', ' ', 'met spatie', '../ontsnapt', 'pad/naam', 'A'.repeat(41), null, 42]) {
toets(`${JSON.stringify(naam)} mag niet als naam`, !isBruikbareNaam(naam));
}
}
// ── Aanmaken, teruglezen, weggooien ───────────────────────────────────────────
{
const waar = map();
const register = openRegister(waar);
toets('een vers register is leeg', register.lijst().length === 0);
toets('en een onbekende naam geeft null', register.zoek('piet') === null);
const toegevoegd = register.voegToe({ naam: 'piet', mnemonic: 'woord ' .repeat(11) + 'woord', ownerId: 'AAA' });
toets('toevoegen geeft de regel terug', toegevoegd.naam === 'piet' && toegevoegd.ownerId === 'AAA');
toets('met een tijdstip erbij', typeof toegevoegd.aangemaakt === 'string');
toets('en hij staat in de lijst', register.lijst().length === 1);
toets('en is op naam te vinden', register.zoek('piet')?.ownerId === 'AAA');
// Het niet-gelukkige pad, en het belangrijkste van dit bestand: overschrijven
// zou de mnemonic van de vorige eigenaar weggooien.
toets(
'dezelfde naam nog eens toevoegen wordt geweigerd',
gooit(() => register.voegToe({ naam: 'piet', mnemonic: 'iets anders', ownerId: 'BBB' })),
);
toets('en de eerste staat er nog', register.zoek('piet')?.ownerId === 'AAA');
toets(
'een onbruikbare naam wordt geweigerd',
gooit(() => register.voegToe({ naam: 'met spatie', mnemonic: 'x', ownerId: 'CCC' })),
);
toets('vergeten van een onbekende naam wordt geweigerd', gooit(() => register.vergeet('jan')));
const weg = register.vergeet('piet');
toets('vergeten geeft de regel terug', weg.ownerId === 'AAA');
toets('en daarna is het register leeg', register.lijst().length === 0);
}
// ── Wat er op schijf komt, komt er weer af ────────────────────────────────────
{
const waar = map();
const eerste = openRegister(waar);
eerste.voegToe({ naam: 'een', mnemonic: 'mnemonic een', ownerId: 'ID1' });
eerste.voegToe({ naam: 'twee', mnemonic: 'mnemonic twee', ownerId: 'ID2' });
const tweede = openRegister(waar);
toets('een tweede register leest hetzelfde bestand', tweede.lijst().length === 2);
toets('in dezelfde volgorde', tweede.lijst()[0].naam === 'een' && tweede.lijst()[1].naam === 'twee');
toets('met de mnemonic erbij', tweede.zoek('twee')?.mnemonic === 'mnemonic twee');
// Een register dat je aanpast mag geen lijst teruggeven die meeverandert:
// dan zou een aanroeper per ongeluk in de staat kunnen schrijven.
const kopie = tweede.lijst();
kopie[0].ownerId = 'GEHACKT';
toets('de lijst is een kopie', tweede.zoek('een')?.ownerId === 'ID1');
}
// ── Een onleesbaar bestand wordt niet stil vervangen ──────────────────────────
{
const waar = map();
const register = openRegister(waar);
register.voegToe({ naam: 'een', mnemonic: 'geheim', ownerId: 'ID1' });
const pad = join(waar, 'owners.json');
const origineel = readFileSync(pad, 'utf8');
writeFileSync(pad, '{ dit is geen json', 'utf8');
toets('onleesbare JSON wordt gemeld', gooit(() => openRegister(waar)));
toets('en het bestand blijft staan', readFileSync(pad, 'utf8') === '{ dit is geen json');
writeFileSync(pad, JSON.stringify({ version: 2, owners: [] }), 'utf8');
toets('een onbekende versie wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, JSON.stringify({ version: 1, owners: [{ naam: 'een' }] }), 'utf8');
toets('een halve regel wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, origineel, 'utf8');
toets('en het originele bestand leest weer goed', openRegister(waar).zoek('een')?.ownerId === 'ID1');
}
// ── normaliseer, los ──────────────────────────────────────────────────────────
{
toets('null is geen staat', normaliseer(null) === null);
toets('een array is geen staat', normaliseer([]) === null);
toets('zonder owners is het geen staat', normaliseer({ version: 1 }) === null);
toets(
'twee keer dezelfde naam is geen staat',
normaliseer({
version: 1,
owners: [
{ naam: 'a', mnemonic: 'x', ownerId: '1' },
{ naam: 'a', mnemonic: 'y', ownerId: '2' },
],
}) === null,
);
toets(
'een lege mnemonic is geen staat',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: '', ownerId: '1' }] }) === null,
);
toets(
'een geldige staat komt er heel uit',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: 'x', ownerId: '1' }] })?.owners[0]
?.naam === 'a',
);
}
// ── De opdrachtregel ──────────────────────────────────────────────────────────
{
toets('niets geeft geen opdracht en geen fout', (() => {
const uit = leesArgumenten([]);
return uit.fout === null && uit.opdracht === null;
})());
toets('een onbekende opdracht is een fout', leesArgumenten(['bestaatniet']).fout !== null);
const nieuw = leesArgumenten(['nieuw', 'piet']);
toets('nieuw piet leest goed', nieuw.fout === null && nieuw.waarden.naam === 'piet');
toets('nieuw zonder naam is een fout', leesArgumenten(['nieuw']).fout !== null);
// Streng, en dat is de bedoeling: `schrijf piet mijn label` zou anders stil
// "mijn" als label nemen en je naar een blob laten kijken die anders heet.
toets('een argument te veel is een fout', leesArgumenten(['nieuw', 'piet', 'jan']).fout !== null);
const schrijf = leesArgumenten(['schrijf', 'piet', 'mijn label', '--bytes', '512']);
toets('schrijf leest beide argumenten', schrijf.waarden.naam === 'piet' && schrijf.waarden.label === 'mijn label');
toets('en de vlag ernaast', schrijf.vlaggen.bytes === '512');
toets('--bytes=512 leest ook', leesArgumenten(['lijst', '--bytes=512']).vlaggen.bytes === '512');
toets('een losse vlag is true', leesArgumenten(['lijst', '--lokaal']).vlaggen.lokaal === true);
toets(
'een losse vlag gevolgd door een vlag blijft true',
leesArgumenten(['lijst', '--lokaal', '--bytes=8']).vlaggen.lokaal === true,
);
toets('elke opdracht heeft uitleg', Object.values(OPDRACHTEN).every((v) => typeof v.uitleg === 'string' && v.uitleg.length > 0));
}
// ── De instellingen ───────────────────────────────────────────────────────────
{
const gelezen = leesEnvBestand(
['# commentaar', '', 'RELAY_URL=ws://voorbeeld:3852', 'MET_QUOTES="waarde"', 'ROMMEL', 'LEEG='].join('\n'),
);
toets('een gewone regel leest', gelezen.RELAY_URL === 'ws://voorbeeld:3852');
toets('aanhalingstekens gaan eraf', gelezen.MET_QUOTES === 'waarde');
toets('een regel zonder = wordt overgeslagen', gelezen.ROMMEL === undefined);
toets('een lege waarde is een lege string', gelezen.LEEG === '');
toets('commentaar telt niet mee', Object.keys(gelezen).length === 3);
toets('ws:// blijft', naarRelayUrl('ws://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('wss:// blijft', naarRelayUrl('wss://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('http:// wordt ws://', naarRelayUrl('http://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('https:// wordt wss://', naarRelayUrl('https://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('zonder schema wordt ws://', naarRelayUrl('umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('spaties eromheen doen niets', naarRelayUrl(' umbrel.local:3852 ') === 'ws://umbrel.local:3852');
toets('een leeg adres wordt geweigerd', gooit(() => naarRelayUrl('')));
toets('een onbekend schema wordt geweigerd', gooit(() => naarRelayUrl('ftp://umbrel.local')));
// Een map zonder .env: dan is er geen relay-adres, en dat is geen fout maar een
// toestand die de UI moet kunnen tonen.
const zonder = leesConfig({ env: {}, wortel: map() });
toets('zonder .env is er geen relay-adres', zonder.relayUrl === null);
toets('en staat de UI-poort op de standaard', zonder.uiPoort === 4380);
const wortel = map();
writeFileSync(join(wortel, '.env'), 'RELAY_URL=umbrel.local:3852\nUI_PORT=5000\n', 'utf8');
const met = leesConfig({ env: {}, wortel });
toets('een .env wordt gelezen', met.relayUrl === 'ws://umbrel.local:3852');
toets('en de poort ook', met.uiPoort === 5000);
const overstemd = leesConfig({ env: { RELAY_URL: 'ws://anders:1234' }, wortel });
toets('de omgeving wint van het bestand', overstemd.relayUrl === 'ws://anders:1234');
}
for (const weg of mappen) rmSync(weg, { recursive: true, force: true });
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+267
View File
@@ -0,0 +1,267 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het toegangsbeleid van de relay: tools/evolu-relay/src/policy.js
//
// Draaien: node tests/test_limiter.mjs
//
// Geen testrunner en geen afhankelijkheden, net als de Python-tests in deze map.
// Geeft 0 terug als alles goed is en 1 bij de eerste fout in de telling.
//
// Waarom dit bestand er is: dit beleid is de enige plek die bepaalt wie er op de
// relay mag schrijven. De duurste fout zit hier niet in een berekening maar in
// een guard, en dat is precies wat hieronder de meeste aandacht krijgt.
//
// **Sinds 0.7.0 is er één weg naar binnen: de wachtlijst.** De getimede leerstand
// is eruit. Twee toetsen hieronder bewaken dat die niet stiekem terugkomt: een
// onbekende eigenaar wordt geweigerd ook als er `learning: true` in het
// staatsbestand staat, en `set-learning` is een onbekende actie.
// ═══════════════════════════════════════════════════════════════════════════════
import {
MAX_OWNER_ID_LENGTH,
MAX_REJECTED,
applyCommand,
createEmptyState,
decideOwner,
normalizeState,
} from '../tools/evolu-relay/src/policy.js';
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
const NU = '2026-09-09T12:00:00.000Z';
const LATER = '2026-09-09T13:00:00.000Z';
const EIGEN = 'owner-van-de-gebruiker';
const VREEMD = 'owner-van-een-ander';
/** Een staat met deze eigenaar erin, toegelaten of niet. */
const metEigenaar = (id, allowed, extra = {}) => ({
...createEmptyState(),
owners: [{ id, allowed, firstSeen: NU, lastSeen: NU, ...extra }],
});
// ── Een verse installatie laat niemand binnen ─────────────────────────────────
{
const leeg = createEmptyState();
toets('een verse staat heeft geen eigenaars', leeg.owners.length === 0);
toets('en een lege wachtlijst', leeg.rejected.length === 0);
toets('en geen leerstand meer', !('learning' in leeg) && !('learningUntil' in leeg));
const uitkomst = decideOwner(leeg, VREEMD, NU);
toets('een onbekende eigenaar wordt geweigerd', uitkomst.allowed === false);
toets('met reden "waiting"', uitkomst.reason === 'waiting');
toets('en belandt op de wachtlijst', uitkomst.state.rejected.length === 1);
toets('met zijn id erbij', uitkomst.state.rejected[0].id === VREEMD);
toets('en één poging', uitkomst.state.rejected[0].attempts === 1);
toets('en de staat is veranderd', uitkomst.changed === true);
toets('en hij staat niet stiekem bij de eigenaars', uitkomst.state.owners.length === 0);
}
// ── De leerstand is er echt uit ───────────────────────────────────────────────
{
// Dit is de toets die de verwijdering van 0.7.0 vasthoudt. Een staatsbestand
// van 0.6.0 heeft deze velden nog, en ze horen geen enkel effect te hebben.
const metOudeVelden = {
...createEmptyState(),
learning: true,
learningUntil: null,
};
const uitkomst = decideOwner(metOudeVelden, VREEMD, NU);
toets('`learning: true` in het bestand laat niemand meer binnen', uitkomst.allowed === false);
toets('en zet hem op de wachtlijst', uitkomst.state.rejected[0]?.id === VREEMD);
const metVenster = {
...createEmptyState(),
learning: true,
learningUntil: LATER,
};
toets(
'ook een venster dat nog niet verlopen is doet niets',
decideOwner(metVenster, VREEMD, NU).allowed === false,
);
const opdracht = applyCommand(createEmptyState(), { action: 'set-learning', value: true }, NU);
toets('set-learning is een onbekende actie geworden', opdracht.error === 'unknown-action');
toets('en verandert niets', opdracht.changed === false);
}
// ── De guard die er het meest toe doet ────────────────────────────────────────
{
// Een geblokkeerde eigenaar mag er nooit in. Dat was tot 0.6.0 het gevaar dat
// de leerstand hem alsnog toeliet; die is er nu uit, maar de guard blijft de
// belangrijkste regel van dit bestand.
const geblokkeerd = metEigenaar(EIGEN, false);
const uitkomst = decideOwner(geblokkeerd, EIGEN, LATER);
toets('een geblokkeerde eigenaar blijft buiten', uitkomst.allowed === false);
toets('met reden "known-blocked"', uitkomst.reason === 'known-blocked');
toets('en komt niet op de wachtlijst', uitkomst.state.rejected.length === 0);
toets('hij blijft geblokkeerd staan', uitkomst.state.owners[0].allowed === false);
toets('en "laatst gezien" wordt bijgewerkt', uitkomst.state.owners[0].lastSeen === LATER);
const toegelaten = decideOwner(metEigenaar(EIGEN, true), EIGEN, LATER);
toets('een toegelaten eigenaar mag erin', toegelaten.allowed === true);
toets('met reden "known-allowed"', toegelaten.reason === 'known-allowed');
toets('en "laatst gezien" wordt bijgewerkt', toegelaten.state.owners[0].lastSeen === LATER);
const zelfdeMoment = decideOwner(metEigenaar(EIGEN, true), EIGEN, NU);
toets('twee keer op hetzelfde moment verandert niets', zelfdeMoment.changed === false);
}
// ── Onbruikbare invoer ────────────────────────────────────────────────────────
{
const leeg = createEmptyState();
for (const onzin of ['', null, 42, {}, [], 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
const uitkomst = decideOwner(leeg, onzin, NU);
toets(`${JSON.stringify(onzin)} wordt geweigerd`, uitkomst.allowed === false);
// Onthouden zou hier juist de weg openzetten om het bestand vol te schrijven.
toets(`${JSON.stringify(onzin)} wordt niet onthouden`, uitkomst.changed === false);
toets(`${JSON.stringify(onzin)} geeft reden invalid-owner-id`, uitkomst.reason === 'invalid-owner-id');
}
toets(
`een id van precies ${MAX_OWNER_ID_LENGTH} tekens mag wel`,
decideOwner(leeg, 'x'.repeat(MAX_OWNER_ID_LENGTH), NU).changed === true,
);
}
// ── De wachtlijst loopt niet vol ──────────────────────────────────────────────
{
let staat = createEmptyState();
for (let i = 0; i < MAX_REJECTED + 10; i += 1) {
staat = decideOwner(staat, `vreemde-${i}`, NU).state;
}
toets('de wachtlijst wordt afgekapt', staat.rejected.length === MAX_REJECTED);
// Nieuwste vooraan: een aanvaller die blijft proberen duwt zijn eigen eerdere
// pogingen weg en niet de eigenaar die er net bij kwam.
toets('de nieuwste staat vooraan', staat.rejected[0].id === `vreemde-${MAX_REJECTED + 9}`);
const nogmaals = decideOwner(staat, `vreemde-${MAX_REJECTED + 9}`, LATER);
toets('een tweede poging telt op', nogmaals.state.rejected[0].attempts === 2);
toets('en zet geen tweede regel neer', nogmaals.state.rejected.length === MAX_REJECTED);
toets('en houdt het eerste moment vast', nogmaals.state.rejected[0].firstSeen === NU);
toets('en werkt het laatste bij', nogmaals.state.rejected[0].lastSeen === LATER);
}
// ── Toelaten vanaf de wachtlijst ──────────────────────────────────────────────
{
// De weg die er sinds 0.7.0 als enige is: iemand staat op de wachtlijst en jij
// laat hem toe.
const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
const toegelaten = applyCommand(gewacht, { action: 'allow', ownerId: VREEMD }, LATER);
toets('toelaten lukt', toegelaten.error === null);
toets('en hij staat bij de eigenaars', toegelaten.state.owners[0]?.id === VREEMD);
toets('als toegelaten', toegelaten.state.owners[0]?.allowed === true);
// De geschiedenis gaat mee. Ging dat mis, dan meldt de pagina "first seen" op
// het moment van de klik terwijl die eigenaar al twintig minuten aan de deur
// stond, en dat is juist bij een eigenaar die je alsnog toelaat wat je wilt zien.
toets('met het eerste moment van de wachtlijst', toegelaten.state.owners[0]?.firstSeen === NU);
// En dit is de klacht van de gebruiker van 09-09-2026, die met de leerstand mee
// verdween: hij mag niet in beide lijsten tegelijk staan.
toets('en hij is van de wachtlijst af', toegelaten.state.rejected.length === 0);
toets('en daarna mag hij erin', decideOwner(toegelaten.state, VREEMD, LATER).allowed === true);
}
// ── De overige opdrachten van de pagina ───────────────────────────────────────
{
const staat = metEigenaar(EIGEN, true);
const geblokkeerd = applyCommand(staat, { action: 'block', ownerId: EIGEN }, LATER);
toets('blokkeren lukt', geblokkeerd.error === null);
toets('en zet allowed op false', geblokkeerd.state.owners[0].allowed === false);
toets(
'blokkeren van een onbekende wordt geweigerd',
applyCommand(staat, { action: 'block', ownerId: VREEMD }, LATER).error === 'unknown-owner',
);
const vergeten = applyCommand(staat, { action: 'forget', ownerId: EIGEN }, LATER);
toets('vergeten lukt', vergeten.error === null);
toets('en de eigenaar is weg', vergeten.state.owners.length === 0);
toets(
'vergeten van een onbekende wordt geweigerd',
applyCommand(staat, { action: 'forget', ownerId: VREEMD }, LATER).error === 'unknown-owner',
);
const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
const weg = applyCommand(gewacht, { action: 'forget', ownerId: VREEMD }, LATER);
toets('iemand van de wachtlijst vergeten lukt ook', weg.error === null);
toets('en dan is de wachtlijst leeg', weg.state.rejected.length === 0);
for (const onzin of [null, 42, 'tekst', {}, { action: 'iets' }]) {
const uitkomst = applyCommand(staat, onzin, NU);
toets(`${JSON.stringify(onzin)} als opdracht verandert niets`, uitkomst.changed === false);
toets(`${JSON.stringify(onzin)} als opdracht geeft een fout`, uitkomst.error !== null);
}
for (const onzin of ['', null, 42, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
toets(
`block met ownerId ${JSON.stringify(onzin)} wordt geweigerd`,
applyCommand(staat, { action: 'block', ownerId: onzin }, NU).error === 'malformed-command',
);
}
}
// ── Wat er van schijf komt ────────────────────────────────────────────────────
{
toets('null is geen staat', normalizeState(null) === null);
toets('een array is geen staat', normalizeState([]) === null);
toets('een verkeerde versie is geen staat', normalizeState({ version: 2, owners: [], rejected: [] }) === null);
toets('zonder owners is het geen staat', normalizeState({ version: 1, rejected: [] }) === null);
toets('zonder rejected is het geen staat', normalizeState({ version: 1, owners: [] }) === null);
// Dit is de toets die de installatie van de gebruiker beschermt: een bestand
// van 0.6.0 heeft `learning` en `learningUntil` en moet gewoon lezen. Zou dat
// afgewezen worden, dan schuift store.js de allowlist opzij en gaat de deur
// dicht voor eigenaars die er al in stonden.
const oud = {
version: 1,
learning: true,
learningUntil: '2026-09-09T12:02:00.000Z',
owners: [{ id: EIGEN, allowed: true, firstSeen: NU, lastSeen: NU }],
rejected: [{ id: VREEMD, firstSeen: NU, lastSeen: NU, attempts: 3 }],
};
const gelezen = normalizeState(oud);
toets('een bestand van 0.6.0 leest gewoon', gelezen !== null);
toets('met de eigenaar erin', gelezen.owners[0].id === EIGEN);
toets('en de wachtlijst erin', gelezen.rejected[0].attempts === 3);
toets('en zonder de oude velden', !('learning' in gelezen) && !('learningUntil' in gelezen));
toets(
'een eigenaar zonder allowed is geen staat',
normalizeState({ version: 1, owners: [{ id: EIGEN }], rejected: [] }) === null,
);
toets(
'een eigenaar met een leeg id is geen staat',
normalizeState({ version: 1, owners: [{ id: '', allowed: true }], rejected: [] }) === null,
);
toets(
'een wachtende zonder id is geen staat',
normalizeState({ version: 1, owners: [], rejected: [{ attempts: 1 }] }) === null,
);
toets(
'een onleesbaar aantal pogingen wordt 1 en geen weigering',
normalizeState({ version: 1, owners: [], rejected: [{ id: VREEMD, attempts: -3 }] })
?.rejected[0].attempts === 1,
);
}
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+173
View File
@@ -0,0 +1,173 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst dat de JavaScript in de statuspagina's parseert, en dat hij dat ook nog
// doet nadat umbreld de template heeft ingevuld.
//
// Draaien: node tests/test_paginas_parsen.mjs
//
// Waarom dit bestand er is. De pagina's zijn met bijna drieduizend regels het
// grootste deel van deze repo en er staat geen enkele toets op: alles eraan is
// handwerk in een browser. Dat blijft zo voor wat het doet, maar één klasse fouten
// hoeft niet op het apparaat gevonden te worden: een tikfout waardoor het script
// helemaal niet loopt. Het gevolg daarvan is een pagina die stil op "unknown"
// blijft staan, en dat lijkt op een agent die niet antwoordt.
//
// Aanleiding: op 30-08-2026 is de pagina van Evolu Relay in zijn geheel opnieuw
// geschreven, negenhonderd regels in één keer. Zo'n wijziging hoort niet ongetoetst
// naar een apparaat te gaan.
//
// De invulling wordt hier nagedaan en niet alleen de bron gelezen, want umbreld
// haalt elke *.template door envsubst en wat er draait is dus niet wat er staat.
// Dat vindt de gevallen waar het weghalen van een accolade-vorm de syntaxis breekt.
//
// LET OP wat deze helft NIET vindt, want dat is precies de fout die dit project
// vreest: een template-literal als `iets ${x}` wordt na de invulling `iets ` en dat
// parseert prima. Hij is dan stil kapot en niet luidruchtig. Wat dát dichthoudt is
// de dollarteken-toets in tests/test_appstore_vorm.py, die élke accolade-vorm
// vlagt die umbrelOS niet zelf invult. Deze toets is de tweede zeef en niet de
// eerste.
//
// Wat dit verder NIET toetst: of het script het juiste doet. Alleen dat het loopt.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const HIER = dirname(fileURLToPath(import.meta.url));
const REPO = join(HIER, '..');
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde, uitleg) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}${uitleg ? ` - ${uitleg}` : ''}`);
};
/** Elke map in de repo-root met een umbrel-app.yml erin. Zelf gevonden, zoals de
* Python-toets over de store dat ook doet: een derde app valt er dan vanzelf
* onder. */
const appMappen = () =>
readdirSync(REPO, { withFileTypes: true })
.filter((regel) => regel.isDirectory())
.map((regel) => regel.name)
.filter((naam) => existsSync(join(REPO, naam, 'umbrel-app.yml')))
.sort();
/** De inhoud van elk <script>-blok zonder src-attribuut. */
const scriptBlokken = (html) => {
const blokken = [];
const patroon = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
let treffer = patroon.exec(html);
while (treffer !== null) {
blokken.push(treffer[1]);
treffer = patroon.exec(html);
}
return blokken;
};
/**
* Doet na wat umbreld met een template doet.
*
* envsubst vervangt `${NAAM}` en `$NAAM` door de waarde uit de omgeving, en door
* een lege string als die variabele niet bestaat. Wat wij hier invullen is een
* plausibele waarde voor de variabelen die de pagina's gebruiken; voor de rest de
* lege string, want dat is precies wat er op het apparaat zou gebeuren.
*/
const vulIn = (tekst) => {
const WAARDEN = { APP_VERSION: '0.5.0' };
return tekst.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (heel, naam) =>
Object.prototype.hasOwnProperty.call(WAARDEN, naam) ? WAARDEN[naam] : '',
);
};
/** Parseert het script zonder het te draaien. Geeft de foutmelding of null. */
const parseert = (bron) => {
try {
// new vm.Script parseert bij het aanmaken en draait niets. Dat is precies wat
// hier nodig is: draaien zou een document en een window vragen die er niet
// zijn, en dan toets je je eigen namaakbrowser.
new vm.Script(bron);
return null;
} catch (error) {
return String(error && error.message ? error.message : error);
}
};
/**
* Waar de pagina van een app staat, en of umbreld hem invult.
*
* Twee plekken: als *.template in de app-map (dan gaat hij door envsubst), of als
* gewoon bestand in het bouwrecept onder tools/<app-id zonder store-id>/ (dan zit
* hij in een eigen image en wordt hij door niemand ingevuld). Electrum Gate is
* sinds 0.1.0 het tweede geval; zie het plan Eigenimage.
*/
const vindPagina = (app) => {
const template = join(REPO, app, 'index.html.template');
if (existsSync(template)) {
return { pad: template, template: true };
}
const kort = app.includes('-') ? app.slice(app.indexOf('-') + 1) : app;
const inImage = join(REPO, 'tools', kort, 'index.html');
if (existsSync(inImage)) {
return { pad: inImage, template: false };
}
return null;
};
for (const app of appMappen()) {
const pagina = vindPagina(app);
if (pagina === null) {
// Geen pagina is geen fout: een app hoeft er geen te hebben.
continue;
}
const bron = readFileSync(pagina.pad, 'utf8');
const blokken = scriptBlokken(bron);
toets(`${app}: de pagina heeft een scriptblok`, blokken.length > 0);
blokken.forEach((blok, index) => {
const naam = `${app}: scriptblok ${index + 1}`;
const rauw = parseert(blok);
toets(`${naam} parseert zoals hij in de repo staat`, rauw === null, rauw);
if (pagina.template) {
const ingevuld = parseert(vulIn(blok));
toets(`${naam} parseert ook nadat umbreld hem invult`, ingevuld === null, ingevuld);
}
});
if (!pagina.template) {
// Een pagina in een image gaat door geen envsubst meer, dus wat er staat is
// wat de browser krijgt. Een accolade-variabele is dan een restant uit de
// template-tijd die letterlijk op de pagina zou belanden.
toets(`${app}: de pagina in de image bevat geen accolade-variabele`, !bron.includes('${'));
}
// De HTML zelf: geen parser, maar wel de fout die je in een bestand van
// tweeduizend regels echt maakt. Een dialog of een header die niet gesloten is,
// laat de rest van de pagina in dat element hangen.
for (const tag of ['html', 'head', 'body', 'header', 'script', 'style', 'dialog', 'details']) {
const open = (bron.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length;
const dicht = (bron.match(new RegExp(`</${tag}>`, 'gi')) || []).length;
if (open === 0 && dicht === 0) {
continue;
}
toets(
`${app}: elke <${tag}> is gesloten`,
open === dicht,
`${open} keer open, ${dicht} keer dicht`,
);
}
}
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+263
View File
@@ -0,0 +1,263 @@
"""Toetst de agent van Evolu Relay: de labels.
Waarom deze test bestaat. De agent van deze app beslist niets over toegang, en dat
is precies waarom hij tot 30-08-2026 ongetoetst kon blijven: hij las een bestand en
legde opdrachten in een postbus, en het beleid eromheen staat in `policy.js` met
`tests/test_limiter.mjs` erop. Met deze release doet hij twee dingen erbij die
eigen logica hebben:
- **hij schrijft zelf een bestand**, `labels.json`, en is daar de enige schrijver
van. Dat is de eerste plek waar de agent staat bijhoudt in plaats van doorgeeft,
en labelen is lezen-wijzigen-schrijven, dus er zit een slot om;
Tot 0.6.0 rekende hij daarnaast het tijdvenster voor nieuwe eigenaars uit. Dat
venster is er in 0.7.0 uit: er is nog één weg naar binnen, en dat is de wachtlijst
met handmatig toelaten. De toetsen daarover zijn met de functie mee verdwenen.
Wat deze test NIET dekt: de HTTP-laag. De handlers zitten in een
BaseHTTPRequestHandler en die is zonder socket niet aan te roepen; wat eronder
hangt, de validatie en het schrijven, is hier wél getoetst. De pagina zelf blijft
handwerk in een browser.
Draaien:
python tests/test_relay_agent.py
De test laadt `tools/evolu-relay/agent.py` rechtstreeks: het bestand dat de
Dockerfile in de image zet. Tot 0.6.0 heette dat `agent.py.template` en stond het
in de app-map; de eerste toets hieronder is uit die tijd en blijft staan.
"""
import sys
# Vóór de imports, want anders is het te laat: Python legt bytecode naast agent.py
# zodra die geïmporteerd wordt, en die rommel hoort niet in de repo. Een keer is
# zo'n .pyc meegegaan in een commit.
sys.dont_write_bytecode = True
import importlib.machinery # noqa: E402
import importlib.util # noqa: E402
import json # noqa: E402
import os # noqa: E402
import tempfile # noqa: E402
from datetime import datetime, timedelta, timezone # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
AGENT = os.path.join(HERE, os.pardir, "tools", "evolu-relay", "agent.py")
def load_agent(state_dir):
"""Laadt agent.py als module, met zijn staat in een tijdelijke map.
De agent leest `RELAY_STATE_DIR` op moduleniveau, dus die moet vóór het laden
in de omgeving staan. Met een expliciete loader, buiten sys.path om.
"""
os.environ["RELAY_STATE_DIR"] = state_dir
loader = importlib.machinery.SourceFileLoader("relay_agent", AGENT)
spec = importlib.util.spec_from_file_location("relay_agent", AGENT, loader=loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def iso(seconden_vanaf_nu):
"""Een tijdstip in de vorm die het relay-proces schrijft: ISO, met een Z."""
when = datetime.now(timezone.utc) + timedelta(seconds=seconden_vanaf_nu)
return when.isoformat().replace("+00:00", "Z")
def schrijf_owners(state_dir, staat):
with open(os.path.join(state_dir, "owners.json"), "w", encoding="utf-8") as f:
json.dump(staat, f)
# ── De aanname waar deze hele test op rust ───────────────────────────────────
def test_template_is_invulbaar_zonder_schade(u):
"""Een restant uit de template-tijd dat blijft staan.
Het bestand zit sinds 0.6.0 in de image en gaat door geen envsubst meer. Maar
als iemand het ooit weer als template in een app-map zet, is dit de toets die
het meteen ziet.
"""
with open(AGENT, "r", encoding="utf-8") as f:
inhoud = f.read()
u.check("agent.py bevat geen dollartekens",
"$" not in inhoud,
"een envsubst-stap zou die invullen en de Python-code slopen")
# ── De labels ───────────────────────────────────────────────────────────────
def test_label_zetten_en_weghalen(agent, u):
u.check("een label zetten lukt", agent.apply_label("owner-a", "Laptop") is None)
u.check("en staat er daarna", agent.read_labels().get("owner-a") == "Laptop")
u.check("overschrijven lukt", agent.apply_label("owner-a", "Werklaptop") is None)
u.check("en vervangt de vorige", agent.read_labels().get("owner-a") == "Werklaptop")
u.check("een leeg label haalt hem weg", agent.apply_label("owner-a", "") is None)
u.check("en dan is hij er niet meer", "owner-a" not in agent.read_labels())
u.check("nog een keer weghalen is geen fout",
agent.apply_label("owner-a", "") is None)
def test_label_blijft_op_schijf(agent, u, state_dir):
"""Het bestand is JSON en te lezen zonder de agent.
Dat is geen formaliteit: dit bestand staat onder de app-datamap en gaat mee in
de back-up van umbrelOS. Een gebruiker die zijn labels kwijt is, moet ze daar
kunnen terugvinden.
"""
agent.apply_label("owner-b", "Telefoon")
with open(os.path.join(state_dir, "labels.json"), "r", encoding="utf-8") as f:
op_schijf = json.load(f)
u.check("het label staat in labels.json", op_schijf.get("owner-b") == "Telefoon")
def test_label_validatie(agent, u):
goed = agent.valid_label({"ownerId": "owner-c", "label": " Mijn laptop "})
u.check("witruimte wordt samengetrokken", goed[1] == "Mijn laptop")
u.check("en er is geen fout", goed[2] is None)
# Regeleindes eruit: dit is één regel naast een id, en een label met een nieuwe
# regel erin zou de lijst uit elkaar trekken.
plat = agent.valid_label({"ownerId": "owner-c", "label": "een\ntwee"})
u.check("een regeleinde wordt een spatie", plat[1] == "een twee")
leeg = agent.valid_label({"ownerId": "owner-c"})
u.check("een ontbrekend label is leeg en geen fout", leeg[1] == "" and leeg[2] is None)
for rommel, waarom in [
({}, "geen ownerId"),
({"ownerId": ""}, "leeg ownerId"),
({"ownerId": 42}, "ownerId is geen tekst"),
({"ownerId": "x" * (agent.MAX_OWNER_ID_LENGTH + 1)}, "ownerId te lang"),
({"ownerId": "owner-c", "label": 42}, "label is geen tekst"),
({"ownerId": "owner-c", "label": "x" * (agent.MAX_LABEL_LENGTH + 1)}, "label te lang"),
("geen object", "geen object"),
]:
u.check("een onbruikbaar label wordt geweigerd: %s" % waarom,
agent.valid_label(rommel)[2] is not None)
def test_labels_lopen_niet_vol(agent, u):
"""Er zit een plafond op. Niet tegen een aanvaller, want deze pagina zit achter
de inlog van umbrelOS, maar tegen een lus die per ongeluk blijft schrijven.
"""
for i in range(agent.MAX_LABELS):
agent.apply_label("bulk-%d" % i, "label %d" % i)
u.check("het plafond is bereikt", len(agent.read_labels()) == agent.MAX_LABELS)
u.check("en een label erboven wordt geweigerd",
agent.apply_label("een-te-veel", "nog een") is not None)
u.check("maar een bestaand label mag nog wél gewijzigd worden",
agent.apply_label("bulk-0", "gewijzigd") is None,
"anders kun je bij een vol bestand niets meer verbeteren")
def test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir):
"""Anders dan owners.json: 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, en het ergste gevolg is dat je de rauwe ids ziet.
"""
with open(os.path.join(state_dir, "labels.json"), "w", encoding="utf-8") as f:
f.write("dit is geen json {{{")
u.check("een onleesbaar labelbestand leest als leeg", agent.read_labels() == {})
with open(os.path.join(state_dir, "labels.json"), "w", encoding="utf-8") as f:
json.dump({"owner-d": "Goed", "owner-e": 42, "": "geen id", "owner-f": " "}, f)
labels = agent.read_labels()
u.check("de goede regel blijft", labels.get("owner-d") == "Goed")
u.check("een label dat geen tekst is valt weg", "owner-e" not in labels)
u.check("een leeg id valt weg", "" not in labels)
u.check("een label van alleen witruimte valt weg", "owner-f" not in labels)
# ── De status ───────────────────────────────────────────────────────────────
def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
"""De pagina krijgt het label bij de eigenaar, en het bestand van de relay
blijft ongemoeid. Dat tweede is de reden dat `met_label` een kopie maakt.
"""
schrijf_owners(state_dir, {
"version": 1,
"learning": False,
"learningUntil": None,
"owners": [
{"id": "owner-g", "allowed": True, "firstSeen": iso(-600), "lastSeen": iso(-60)},
{"id": "owner-h", "allowed": False, "firstSeen": iso(-900), "lastSeen": None},
],
"rejected": [{"id": "owner-i", "firstSeen": iso(-300), "lastSeen": iso(-10),
"attempts": 3}],
})
agent.apply_label("owner-g", "Laptop")
agent.apply_label("owner-i", "Onbekend apparaat")
status = agent.build_status()
owners = status["owners"]
u.check("de toegelaten eigenaar krijgt zijn label",
owners["allowed"][0]["label"] == "Laptop")
u.check("de geblokkeerde staat in blocked", owners["blocked"][0]["id"] == "owner-h")
u.check("en heeft geen label", owners["blocked"][0]["label"] is None)
u.check("de geweigerde poging staat in rejected", owners["rejected"][0]["id"] == "owner-i")
u.check("en krijgt zijn label", owners["rejected"][0]["label"] == "Onbekend apparaat")
u.check("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3)
# De leerstand is er in 0.7.0 uit. Deze toets houdt vast dat de agent hem ook
# niet meer dóórgeeft: een pagina die het veld nog zou lezen, zou anders een
# knop kunnen tonen voor iets dat niet meer bestaat.
u.check("de leerstand staat niet meer in de status",
"learning" not in owners and "learningSecondsLeft" not in owners)
with open(os.path.join(state_dir, "owners.json"), "r", encoding="utf-8") as f:
op_schijf = json.load(f)
u.check("owners.json is niet aangeraakt",
all("label" not in regel for regel in op_schijf["owners"]),
"de agent hoort niet in het bestand van de relay te schrijven")
def main():
u = Uitslag()
test_template_is_invulbaar_zonder_schade(u)
with tempfile.TemporaryDirectory() as state_dir:
agent = load_agent(state_dir)
test_label_zetten_en_weghalen(agent, u)
test_label_blijft_op_schijf(agent, u, state_dir)
test_label_validatie(agent, u)
test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir)
test_status_hangt_labels_aan_de_regels(agent, u, state_dir)
# Als laatste: deze vult het labelbestand tot het plafond en laat dus geen
# bruikbare staat achter voor een toets erna.
test_labels_lopen_niet_vol(agent, u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het herschrijven in tools/release-bump.mjs.
//
// Draaien: node tests/test_release_bump.mjs
//
// WAAROM DIT BESTAND ER IS. Dat script bestaat om één stille fout onmogelijk te
// maken: een OUDE digest onder een NIEUWE tag levert de oude image en er is niets
// dat dat meldt. Een script dat die fout zelf maakt is erger dan overtypen, want
// dan vertrouw je erop. Vandaar dat de vervanging hier op de niet-gelukkige paden
// getoetst wordt en niet alleen op het geval waarin alles klopt.
//
// De netwerkcontrole (hoort die digest bij die tag) staat hier NIET in: die vraagt
// het register en dat is geen toets maar een meting. Wat hier staat is wat zonder
// netwerk te bewijzen valt.
// ═══════════════════════════════════════════════════════════════════════════════
import assert from 'node:assert/strict';
import {
herschrijfImage, herschrijfVersie, huidigeAppVersie, kiesAppVersie, isHoger,
} from '../tools/release-bump.mjs';
const DIGEST = `sha256:${'a'.repeat(64)}`;
const OUD = `sha256:${'b'.repeat(64)}`;
function test(naam, fn) {
try {
fn();
console.log(` ok ${naam}`);
} catch (fout) {
console.log(` FOUT ${naam}`);
console.log(` ${fout.message}`);
process.exitCode = 1;
}
}
console.log('herschrijfImage');
test('vervangt de hele regel, dus de oude digest kan niet blijven staan', () => {
const compose = `services:\n server:\n image: sc.kamenier-hamer.nl/sysop/atreides-assets:0.1.0@${OUD}\n restart: on-failure\n`;
const { tekst, naam, aantal } = herschrijfImage(compose, '0.1.3', DIGEST);
assert.equal(naam, 'sc.kamenier-hamer.nl/sysop/atreides-assets');
assert.equal(aantal, 1);
assert.ok(tekst.includes(`atreides-assets:0.1.3@${DIGEST}`));
assert.ok(!tekst.includes(OUD), 'de oude digest staat er nog');
assert.ok(tekst.includes(' restart: on-failure'), 'de rest van het bestand is geraakt');
});
test('en houdt de inspringing aan die er stond', () => {
const compose = ` image: sc.kamenier-hamer.nl/sysop/x:0.1.0\n`;
assert.equal(herschrijfImage(compose, '0.2.0', DIGEST).tekst, ` image: sc.kamenier-hamer.nl/sysop/x:0.2.0@${DIGEST}\n`);
});
test('neemt twee containers uit hetzelfde image samen mee', () => {
const compose = ` a:\n image: sc.kamenier-hamer.nl/sysop/gate:0.1.0@${OUD}\n b:\n image: sc.kamenier-hamer.nl/sysop/gate:0.1.0@${OUD}\n`;
const { aantal, tekst } = herschrijfImage(compose, '0.1.1', DIGEST);
assert.equal(aantal, 2);
assert.equal(tekst.match(/0\.1\.1/g).length, 2);
});
test('laat een image van een ANDERE host met rust', () => {
const compose = ` db:\n image: postgres:16-alpine\n app:\n image: sc.kamenier-hamer.nl/sysop/relay:0.7.1\n`;
const { tekst, aantal } = herschrijfImage(compose, '0.7.2', DIGEST);
assert.equal(aantal, 1);
assert.ok(tekst.includes('image: postgres:16-alpine'), 'de vreemde image is meeverschoven');
});
test('weigert een compose zonder eigen image in plaats van er een bij te zetten', () => {
assert.throws(() => herschrijfImage(' db:\n image: postgres:16\n', '1.0.0', DIGEST), /geen image-regel/);
});
test('weigert twee verschillende eigen images, want dan is de bedoeling niet af te leiden', () => {
const compose = ` image: sc.kamenier-hamer.nl/sysop/a:0.1.0\n image: sc.kamenier-hamer.nl/sysop/b:0.1.0\n`;
assert.throws(() => herschrijfImage(compose, '0.2.0', DIGEST), /meerdere eigen images/);
});
test('weigert een digest die niet de vorm heeft, want dat is de plakfout die telt', () => {
const compose = ` image: sc.kamenier-hamer.nl/sysop/x:0.1.0\n`;
assert.throws(() => herschrijfImage(compose, '0.2.0', 'sha256:kort'), /digest heeft niet de vorm/);
assert.throws(() => herschrijfImage(compose, '0.2.0', 'a'.repeat(64)), /digest heeft niet de vorm/);
assert.throws(() => herschrijfImage(compose, 'v0.2.0', DIGEST), /versie heeft niet de vorm/);
});
console.log('de app-versie');
test('leest de huidige versie, met en zonder aanhalingstekens', () => {
assert.equal(huidigeAppVersie('id: x\nversion: "0.1.2"\nname: X\n'), '0.1.2');
assert.equal(huidigeAppVersie('version: 1.2.3\n'), '1.2.3');
});
test('vergelijkt per deel en niet als tekst', () => {
assert.equal(isHoger('0.10.0', '0.9.0'), true);
assert.equal(isHoger('0.9.0', '0.10.0'), false);
assert.equal(isHoger('1.0.0', '0.99.99'), true);
});
test('trekt gelijk als de image erboven ligt, en verhoogt anders de patch', () => {
assert.equal(kiesAppVersie('0.1.2', '0.1.3'), '0.1.3');
assert.equal(kiesAppVersie('0.2.0', '0.1.3'), '0.2.1');
// Gelijk is ook een verhoging waard: zonder verhoging rolt umbrelOS niets uit.
assert.equal(kiesAppVersie('0.1.3', '0.1.3'), '0.1.4');
});
test('schrijft de versie met aanhalingstekens terug en laat de rest staan', () => {
const manifest = 'id: x\nversion: "0.1.2"\nname: X\n';
assert.equal(herschrijfVersie(manifest, '0.1.3'), 'id: x\nversion: "0.1.3"\nname: X\n');
});
test('weigert een manifest zonder versie in plaats van er een bij te zetten', () => {
assert.throws(() => herschrijfVersie('id: x\nname: X\n', '0.1.3'), /geen `version:`/);
assert.throws(() => huidigeAppVersie('id: x\n'), /geen `version:`/);
});
if (process.exitCode) console.log('\nER ZIJN FOUTEN');
else console.log('\nalles goed');
@@ -0,0 +1,549 @@
"""Toetst dat de pagina omhoog komt zonder certificaat.
Waarom deze test bestaat. In 0.0.3 startte de app niet: het stream-blok van
nginx had een 'listen 50022 ssl' met een include naar de cert.conf van de agent,
en nginx weigert te starten als dat certificaat er niet is. Daardoor kwam ook de
web-UI niet omhoog, en dat is precies de pagina waarop je een certificaat kiest.
Wie geen reverse proxy draait, of twee kandidaten heeft, zag dus niets en kon
niets. De symptomen wezen ergens anders heen: de app_proxy meldde alleen dat de
server niet te bereiken was.
De reparatie is een verplaatsing, en die is niet aan de code af te lezen: het
stream-blok staat in stream.conf en wordt door entrypoint.sh pas in
/var/lib/gate/tls/ gezet als cert.conf bestaat. nginx.conf haalt die map op met
een jokerteken, en een jokerteken dat niets matcht is voor nginx geen fout. Eén
iemand die dat blok "netjes" terugzet in nginx.conf en de klem is terug, zonder
dat een van de andere toetsen iets merkt.
Sinds 0.1.0 zitten deze bestanden in een eigen image en staan ze in
tools/electrum-gate/; tot dan waren het *.template-bestanden in de app-map en
was het startscript een command-blok in de compose. De toetsen zijn mee
verhuisd en toetsen hetzelfde gedrag, plus wat er bij die verhuizing nieuw
kapot kan: de Dockerfile die een bestand niet meeneemt, en de plaatshouders in
stream.conf die entrypoint.sh moet invullen.
Draaien:
python tests/test_server_start_zonder_certificaat.py
Er staan een paar toetsen bij die niet over het starten gaan maar wel over
dezelfde soort fout: een die niets meldt en pas opvalt als iemand het toevallig
ziet. De verbindingsteller die stil nul telt, een mount die weer alleen-lezen wordt,
en twee taglines die uit elkaar lopen. Ze staan hier en niet in een derde bestand,
want de suite heeft geen runner en een bestand dat niemand aanroept toetst niets.
"""
import sys
sys.dont_write_bytecode = True
import os # noqa: E402
import re # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
APP = os.path.join(HERE, os.pardir, "whatsnext-electrum-gate")
RECEPT = os.path.join(HERE, os.pardir, "tools", "electrum-gate")
NGINX_CONF = os.path.join(RECEPT, "nginx.conf")
STREAM_CONF = os.path.join(RECEPT, "stream.conf")
ENTRYPOINT = os.path.join(RECEPT, "entrypoint.sh")
DOCKERFILE = os.path.join(RECEPT, "Dockerfile")
BUILD = os.path.join(RECEPT, "build.sh")
PAGINA = os.path.join(RECEPT, "index.html")
COMPOSE = os.path.join(APP, "docker-compose.yml")
MANIFEST = os.path.join(APP, "umbrel-app.yml")
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def lees(pad):
with open(pad, "r", encoding="utf-8") as f:
return f.read()
def zonder_commentaar(tekst):
"""De regels die nginx of de shell daadwerkelijk uitvoert.
Zonder dit zou elke toets hieronder afgaan op de uitleg erboven, en die
noemt juist de dingen die er niet meer mogen staan.
"""
regels = []
for regel in tekst.splitlines():
kaal = regel.strip()
if not kaal or kaal.startswith("#"):
continue
regels.append(kaal)
return regels
def test_pagina_hangt_niet_aan_een_certificaat(u):
"""Geen TLS-directive in nginx.conf, want die blokkeert de start."""
regels = zonder_commentaar(lees(NGINX_CONF))
u.check("nginx.conf heeft geen listen met ssl",
not any("listen" in r and "ssl" in r for r in regels),
"een 'listen ssl' zonder certificaat laat nginx niet starten, "
"en dan is er ook geen pagina om er een te kiezen")
u.check("nginx.conf includet cert.conf niet rechtstreeks",
not any("cert.conf" in r for r in regels),
"een include van een bestand dat er nog niet is, is een startfout")
u.check("nginx.conf haalt het stream-blok met een jokerteken op",
any(r.startswith("include") and "/var/lib/gate/tls/*" in r
for r in regels),
"zonder jokerteken is een lege map alsnog een startfout")
u.check("de pagina zelf luistert nog wel",
any(r.startswith("listen 80") for r in regels))
def test_stream_blok_staat_apart(u):
"""Het TLS-deel is compleet, en het backend-adres komt uit de omgeving."""
u.check("stream.conf bestaat", os.path.isfile(STREAM_CONF))
if not os.path.isfile(STREAM_CONF):
return
regels = zonder_commentaar(lees(STREAM_CONF))
u.check("stream.conf luistert op 50022 met ssl",
any("listen 50022 ssl" in r for r in regels))
u.check("stream.conf includet de cert.conf van de agent",
any("include /var/lib/gate/cert.conf" in r for r in regels))
u.check("stream.conf heeft zijn log_format zelf",
any(r.startswith("log_format gate") for r in regels),
"tot 0.0.29 schreef het command-blok dat in een los bestand, omdat "
"een dollarteken in een template leeggemaakt werd; dat hoeft niet meer")
u.check("en schrijft de sessielog waar de agent hem leest",
any(r.startswith("access_log /var/lib/gate/stream.log") for r in regels))
# Het adres van de backend staat niet in dit bestand maar komt uit de
# omgeving, en de weg daarheen is een plaatshouder die entrypoint.sh invult.
# Geen dollarteken in die plaatshouder: dan kan geen envsubst-stap, van
# umbreld of van wie dan ook, hem stil leegmaken.
proxy = [r for r in regels if r.startswith("proxy_pass")]
u.check("stream.conf heeft precies één proxy_pass", len(proxy) == 1,
"gevonden: %r" % proxy)
u.check("en die verwijst naar de plaatshouders voor host en poort",
proxy and "__GATE_ELECTRUM_HOST__:__GATE_ELECTRUM_PORT__" in proxy[0],
"gevonden: %r" % proxy)
u.check("stream.conf bevat geen accolade-variabele meer",
"${" not in lees(STREAM_CONF),
"die wordt sinds de eigen image door niemand meer ingevuld en zou "
"letterlijk in de configuratie belanden")
def test_entrypoint_wacht_niet_op_de_agent(u):
"""entrypoint.sh start nginx altijd, en zet TLS erbij als het kan."""
u.check("entrypoint.sh bestaat", os.path.isfile(ENTRYPOINT))
if not os.path.isfile(ENTRYPOINT):
return
tekst = lees(ENTRYPOINT)
regels = zonder_commentaar(tekst)
u.check("geen wachtlus op cert.conf",
not any("! -f" in r and "cert.conf" in r and "while" in r
for r in regels),
"dat is precies de klem uit 0.0.3: geen certificaat, geen nginx, "
"geen pagina, dus geen manier om een certificaat te kiezen")
u.check("het stream-blok wordt ingevuld uit de omgeving",
any("__GATE_ELECTRUM_HOST__" in r and "GATE_ELECTRUM_HOST" in r
for r in regels)
and any("__GATE_ELECTRUM_PORT__" in r for r in regels),
"anders staat de plaatshouder letterlijk in de proxy_pass")
u.check("en wordt uit stream.conf.in gelezen, zoals de Dockerfile hem neerzet",
any("/etc/nginx/stream.conf.in" in r for r in regels))
u.check("het stream-blok wordt weggezet als cert.conf bestaat",
any(r.startswith("if cp /etc/nginx/stream.conf") and "/tls/" in r
for r in regels))
u.check("en weggehaald als cert.conf er niet is",
any(r.startswith("rm -f") and "/tls/stream.conf" in r for r in regels),
"een ingetrokken keuze moet de poort ook echt sluiten")
u.check("een mislukte herlading breekt de wachtlus niet af",
any("if ! nginx -s reload" in r for r in regels),
"onder 'set -e' zou de lus verdwijnen en daarna pikt niets meer "
"een wijziging op")
u.check("nginx wordt met exec het hoofdproces",
regels and regels[-1].startswith("exec nginx"),
"anders blijft de shell PID 1, geeft signalen niet door, en ziet "
"Docker een geslaagde afsluiting als nginx omvalt; laatste regel: %r"
% (regels[-1] if regels else None))
u.check("het script begint met een shebang",
tekst.startswith("#!/bin/sh"))
u.check("geen verdubbelde dollartekens meer",
"$$" not in "\n".join(regels),
"dat was de ontsnapping voor het command-blok in de compose; in een "
"gewoon script is $$ het proces-id")
def test_dockerfile_neemt_alles_mee(u):
"""Wat entrypoint.sh en nginx.conf verwachten, zet de Dockerfile ook neer.
Dit is de nieuwe klasse fouten van de eigen image: een bestand dat in de
repo staat maar niet in de image, en dan faalt de container pas op het
apparaat. Geen Docker nodig om dat te zien; de paden staan in de tekst.
"""
u.check("Dockerfile bestaat", os.path.isfile(DOCKERFILE))
if not os.path.isfile(DOCKERFILE):
return
regels = zonder_commentaar(lees(DOCKERFILE))
copies = [r for r in regels if r.startswith("COPY ")]
def kopieert(bron, doel):
return any(bron in r and r.endswith(" " + doel) for r in copies)
u.check("nginx.conf gaat naar /etc/nginx/nginx.conf",
kopieert("tools/electrum-gate/nginx.conf", "/etc/nginx/nginx.conf"))
u.check("stream.conf gaat naar /etc/nginx/stream.conf.in",
kopieert("tools/electrum-gate/stream.conf", "/etc/nginx/stream.conf.in"),
"entrypoint.sh leest dat pad en vult het in naar stream.conf")
u.check("de pagina gaat naar de webroot",
kopieert("tools/electrum-gate/index.html", "/usr/share/nginx/html/index.html"))
u.check("het icoon komt uit de app-map",
kopieert("whatsnext-electrum-gate/icon.png", "/usr/share/nginx/html/icon.png"),
"de app-map houdt het origineel omdat het manifest ernaar wijst; "
"de image kopieert het, zodat er niet twee exemplaren zijn")
u.check("de agent gaat naar /app/agent.py",
kopieert("tools/electrum-gate/agent.py", "/app/agent.py"),
"de compose start hem op dat pad")
u.check("entrypoint.sh gaat mee en wordt uitvoerbaar",
kopieert("tools/electrum-gate/entrypoint.sh", "/entrypoint.sh")
and any(r.startswith("RUN chmod") and "/entrypoint.sh" in r for r in regels),
"git op Windows kent geen uitvoerbit, dus die moet de Dockerfile zetten")
u.check("python3 wordt geïnstalleerd",
any(r.startswith("RUN apk add") and "python3" in r for r in regels))
u.check("de basisimage is nginx op een vaste tag",
any(re.match(r"FROM nginx:\d+\.\d+-alpine$", r) for r in regels),
"nginx:alpine beweegt mee en is ook mét digest niet toegestaan; "
"zie HomeGit/Docs/Referenties/UmbrelApps/Images-pinnen.md")
u.check("het entrypoint van de nginx-image is uitgeschakeld",
"ENTRYPOINT []" in regels,
"anders draait het envsubst-script van die image mee, en dat is "
"precies het soort stille invulling dat dit project vreest")
# De compose noemt paden in de image; die moeten er zijn.
compose = zonder_commentaar(lees(COMPOSE))
u.check("de compose start de agent op het pad waar de Dockerfile hem neerzet",
any(r == "- /app/agent.py" for r in compose))
def test_compose_en_recept_lopen_gelijk(u):
"""Dezelfde image in beide services, met de tag uit build.sh.
Twee services die elk hun eigen tag hebben lopen uit elkaar bij de eerste
verhoging die iemand op één plek doet. En een tag die niet gelijk is aan
VERSION in build.sh betekent een image die nooit gebouwd is.
"""
compose = zonder_commentaar(lees(COMPOSE))
images = [r.split(":", 1)[1].strip() for r in compose if r.startswith("image:")]
u.check("de compose heeft twee images", len(images) == 2, "gevonden: %r" % images)
tags = set()
for image in images:
zonder_digest = image.split("@", 1)[0]
tags.add(zonder_digest)
u.check("en het is twee keer dezelfde", len(tags) == 1, "gevonden: %r" % sorted(tags))
versie = None
for regel in zonder_commentaar(lees(BUILD)):
if regel.startswith("VERSION="):
versie = regel.split("=", 1)[1].strip().strip('"')
u.check("build.sh heeft een VERSION", bool(versie))
if versie and tags:
tag = sorted(tags)[0]
u.check("de tag in de compose is VERSION uit build.sh",
tag.endswith(":" + versie),
"compose %r, build.sh %r" % (tag, versie))
# Wat er niet meer gemount mag worden: de programmabestanden. Zit er weer
# een mount van een los bestand uit de app-map in, dan is dat een terugval
# naar de template-tijd en bereikt een wijziging het apparaat via twee wegen.
losse = [r for r in compose if r.startswith("- ${APP_DATA_DIR}/")
and "/" not in r[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]]
u.check("geen losse bestanden uit de app-map meer gemount",
not losse, "gevonden: %r" % losse)
u.check("de server krijgt het backend-adres uit de omgeving",
compose.count("GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}") == 2
and compose.count("GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}") == 2,
"agent én server hebben het nodig; entrypoint.sh vult er het "
"stream-blok mee in")
u.check("de agent krijgt de versie van het manifest door",
"GATE_APP_VERSION: ${APP_VERSION}" in compose,
"anders toont de kop van de pagina geen versie")
def test_uploadpad(u):
"""De drie dingen buiten de agent die uploaden mogelijk maken.
Alle drie kunnen ze stil wegvallen: een mount die weer `:ro` wordt geeft een
foutmelding pas als iemand iets uploadt, en een te kleine limiet in nginx
geeft een 413 die niets uitlegt.
"""
compose = zonder_commentaar(lees(COMPOSE))
nginx = zonder_commentaar(lees(NGINX_CONF))
eigen = [r for r in compose if "/certs/own" in r and r.startswith("-")]
u.check("de eigen certificaatmap is twee keer gemount",
len(eigen) == 2, "gevonden: %r" % eigen)
u.check("en precies één daarvan is beschrijfbaar",
len([r for r in eigen if not r.endswith(":ro")]) == 1,
"zonder :ro kan de agent niets neerzetten, met :ro overal ook niet: %r"
% eigen)
u.check("GATE_UPLOAD_DIR staat in de omgeving van de agent",
any(r.startswith("GATE_UPLOAD_DIR:") for r in compose),
"leeg of afwezig betekent: uploaden staat uit")
u.check("nginx heeft een eigen locatie voor de upload",
any("location = /api/certificate/upload" in r for r in nginx))
# De limiet moet groter zijn dan die van /api/, anders kapt nginx het verzoek
# af voordat de agent er iets over kan zeggen.
limieten = []
for regel in nginx:
if regel.startswith("client_max_body_size"):
limieten.append(regel.rstrip(";").split()[-1])
u.check("en een grotere limiet dan het gewone api-pad",
len(limieten) == 2 and limieten[0] != limieten[1],
"gevonden limieten: %r" % limieten)
def test_verbindingsteller(u):
"""De teller in de nginx-container, en de poort in hex.
Het gevaarlijke deel is de hex: de teller zoekt in /proc/net/tcp op de poort
in hexadecimale vorm, en verandert de TLS-poort ooit, dan telt hij stil nul.
Een teller die altijd nul zegt, ziet eruit als "geen wallet verbonden" en
niet als een fout.
"""
regels = zonder_commentaar(lees(ENTRYPOINT))
teller = [r for r in regels if "/proc/net/tcp" in r]
u.check("de teller leest /proc/net/tcp", bool(teller),
"dat is de enige plek waar de open verbindingen van deze container "
"staan; de agent zit in een andere netwerk-namespace")
u.check("en schrijft het aantal weg voor de agent",
any("/sessions" in r for r in regels))
# De poort uit de omgeving van de agent is de waarheid; de teller moet
# dezelfde poort in hex zoeken.
compose = zonder_commentaar(lees(COMPOSE))
poorten = [r for r in compose if r.startswith("GATE_TLS_PORT:")]
u.check("GATE_TLS_PORT staat in de compose", len(poorten) == 1,
"gevonden: %r" % poorten)
if not poorten or not teller:
return
poort = int(poorten[0].split(":", 1)[1].strip().strip('"'))
hexpoort = "%X" % poort
# Over de regels heen zoeken, want de awk-regel staat achter een
# backslash-vervolg en dan valt de hex op een eigen regel.
u.check("de teller zoekt de TLS-poort in hex (%d is %s)" % (poort, hexpoort),
(":" + hexpoort) in " ".join(regels),
"nergens in entrypoint.sh staat :%s, dus telt de teller nul"
% hexpoort)
stream = zonder_commentaar(lees(STREAM_CONF))
u.check("en stream.conf luistert op diezelfde poort",
any(r == "listen %d ssl;" % poort for r in stream))
def test_data_onder_data(u):
"""Alles wat de app zelf schrijft staat onder `data/`.
De conventie van de officiele appstore, afgelezen aan echte apps: electrs
mount `${APP_DATA_DIR}/data/electrs`, mempool `${APP_DATA_DIR}/data`. Deze app
had `runtime/` en `certs/` naast de templates staan. Verplaatst op 20-08-2026
met het oog op publicatie.
Waarom er een toets op staat: bij de volgende mount die iemand toevoegt is dit
precies het detail dat je vergeet, en niets gaat er stuk van. Het valt pas op
bij het inleveren.
"""
regels = zonder_commentaar(lees(COMPOSE))
mounts = [r[1:].strip() for r in regels
if r.startswith("-") and "${APP_DATA_DIR}" in r]
u.check("er zijn mounts uit de app-datamap", bool(mounts))
fout = []
for mount in mounts:
host = mount.strip('"').split(":")[0].replace("${APP_DATA_DIR}/", "")
if not host.startswith("data/"):
fout.append(host)
u.check("elke mount uit de app-map staat onder data/",
not fout, "deze niet: %r" % fout)
u.check("de gedeelde toestand staat onder data/",
any("${APP_DATA_DIR}/data/runtime:" in m for m in mounts))
u.check("de eigen certificaatmap staat onder data/",
any("${APP_DATA_DIR}/data/certs:" in m for m in mounts))
# En het manifest moet die paden ook noemen, anders wijst backupIgnore naar
# iets wat niet bestaat en gaat de sessielog alsnog mee in elke back-up.
manifest = lees(MANIFEST)
u.check("backupIgnore staat in het manifest", "backupIgnore:" in manifest)
for pad in ("data/runtime/stream.log", "data/runtime/status.json"):
u.check("backupIgnore noemt %s" % pad, pad in manifest)
# De volgorde die de packaging-documentatie van de officiele appstore voorschrijft.
# Wat de spec niet noemt, zoals icon en backupIgnore, hoort daarachter en niet
# ertussen: dan is de kop van het manifest letterlijk goed.
MANIFEST_ORDE = [
"manifestVersion", "id", "category", "name", "version", "tagline",
"description", "releaseNotes", "developer", "website", "dependencies",
"repo", "support", "port", "gallery", "path",
]
def manifest_velden():
"""De veldnamen op het hoogste niveau, in de volgorde van het bestand."""
velden = []
for regel in lees(MANIFEST).splitlines():
if not regel or regel[0] in " #-":
continue
if ":" in regel:
velden.append(regel.split(":", 1)[0])
return velden
def test_manifest_volgorde(u):
"""De velden staan in de voorgeschreven volgorde.
Geen smaak: de officiele appstore schrijft deze volgorde voor. Een toets
hierop omdat een nieuw veld standaard onderaan of middenin belandt, en er
niets van stukgaat; het valt pas op bij het inleveren.
"""
velden = manifest_velden()
kern = [v for v in velden if v in MANIFEST_ORDE]
ontbreekt = [v for v in MANIFEST_ORDE if v not in velden]
u.check("alle voorgeschreven velden staan in het manifest",
not ontbreekt, "mist: %r" % ontbreekt)
u.check("en in de voorgeschreven volgorde",
kern == MANIFEST_ORDE, "de volgorde is nu: %r" % kern)
# De extra's horen achter de voorgeschreven reeks. Zo is de kop van het
# bestand letterlijk op orde en hoeft er bij inlevering alleen iets weg.
if kern == MANIFEST_ORDE and velden:
laatste_kern = velden.index(MANIFEST_ORDE[-1])
extra_ervoor = [v for v in velden[:laatste_kern] if v not in MANIFEST_ORDE]
u.check("en wat de spec niet noemt staat erachter",
not extra_ervoor, "deze staan ertussen: %r" % extra_ervoor)
u.check("de lege standaard-inloggegevens zijn eruit",
"defaultUsername" not in velden and "defaultPassword" not in velden,
"deze app heeft geen eigen inlog, dus een leeg veld zegt niets")
def test_releasenotes_hebben_een_geschiedenis(u):
"""Eén verhaal over deze versie, dan één regel per eerdere versie.
Deze toets bestaat om een echte fout: drie versies achter elkaar kwam er een
nieuwe kop bovenop de release notes terwijl de oude tekst eronder bleef staan.
In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer dezelfde
regel over 0.0.4. Dat is zichtbare tekst in de appstore, en niemand leest zijn
eigen release notes nog een keer na.
"""
tekst = lees(MANIFEST)
u.check("er staat precies een keer een geschiedenis-kop",
tekst.count("Earlier releases:") == 1,
"gevonden: %d keer" % tekst.count("Earlier releases:"))
# Elke eerdere versie mag maar een keer genoemd worden.
dubbel = []
for versie in ("0.0.4", "0.0.5", "0.0.6", "0.0.7", "0.0.8"):
aantal = tekst.count("\n " + versie + " ")
if aantal > 1:
dubbel.append((versie, aantal))
u.check("en geen versie wordt twee keer beschreven",
not dubbel, "dubbel: %r" % dubbel)
def test_tagline_is_overal_dezelfde(u):
"""De regel onder de app-naam staat op twee plekken en moet gelijk zijn.
Besloten door de gebruiker op 20-08-2026, nadat hij zag dat de winkel en de
pagina iets anders zeiden. Eén app, één regel. Deze toets bestaat omdat de
twee plekken ver uit elkaar staan: niemand die de tagline in het manifest
aanpast, opent daarna de pagina.
"""
tagline = None
for regel in lees(MANIFEST).splitlines():
if regel.startswith("tagline:"):
tagline = regel.split(":", 1)[1].strip().strip('"')
break
u.check("het manifest heeft een tagline", bool(tagline))
if not tagline:
return
u.check("en de pagina gebruikt woordelijk dezelfde regel",
tagline in lees(PAGINA),
"de pagina noemt hem niet: %r" % tagline)
def test_pagina_zonder_invulling(u):
"""De pagina zit in de image en wordt door niemand meer ingevuld.
Een ${...} die blijft staan komt letterlijk in de browser terecht. En de twee
waarden die umbreld vroeger invulde, de versie en het backend-adres, moeten
nu uit status.json komen; anders is de kop leeg en het adres ook.
"""
tekst = lees(PAGINA)
u.check("de pagina bevat geen accolade-variabele", "${" not in tekst)
u.check("de kop haalt de versie uit status.json",
"s.version" in tekst and 'id="head-version"' in tekst)
u.check("het backend-adres komt uit status.json",
"backend.address" in tekst)
def main():
u = Uitslag()
test_pagina_hangt_niet_aan_een_certificaat(u)
test_stream_blok_staat_apart(u)
test_entrypoint_wacht_niet_op_de_agent(u)
test_dockerfile_neemt_alles_mee(u)
test_compose_en_recept_lopen_gelijk(u)
test_verbindingsteller(u)
test_uploadpad(u)
test_data_onder_data(u)
test_manifest_volgorde(u)
test_releasenotes_hebben_een_geschiedenis(u)
test_tagline_is_overal_dezelfde(u)
test_pagina_zonder_invulling(u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════════════════════════
# De image voor de app whatsnext-electrum-gate.
#
# Eén image voor twee containers. De compose start hem twee keer: als `server`
# met het standaardcommando (entrypoint.sh, dat nginx wordt) en als `agent` met
# `python3 /app/agent.py`. Twee images was netter gescheiden geweest, maar het
# verdubbelt het bouw- en pinwerk, en nginx plus python3 in één alpine is klein.
# Beslist bij de start van het plan Eigenimage, OPEN.md punt 2.
#
# Tot 0.0.29 stonden deze bestanden als *.template in de app-map en draaiden ze
# op python:3-alpine en nginx:alpine. Wat dat kostte staat in het plan
# Eigenimage, PLAN.md §3; het kortste antwoord: het command-blok van de compose
# was ruim honderd regels shell in een YAML-string met verdubbelde dollartekens,
# en dat is nu een gewoon script.
#
# De bouwcontext is de REPO-ROOT, niet deze map. Dat is omdat icon.png in de
# app-map blijft staan (het manifest wijst ernaar voor de tegel in de winkel) en
# de pagina hem óók nodig heeft; kopiëren uit de app-map voorkomt een tweede
# exemplaar dat stil achterloopt. build.sh geeft die context mee.
# ═══════════════════════════════════════════════════════════════════════════════
# nginx en niet python als basis: de officiële nginx-image heeft de stream-module
# ingebouwd, en op alpine's eigen nginx-pakket is dat een los pakket. De app draaide
# al op nginx:alpine, dus dit houdt nginx precies zoals hij was. De tag is de
# stable-reeks (even tweede cijfer); zie HomeGit/Docs/Referenties/UmbrelApps/Images-pinnen.md.
FROM nginx:1.30-alpine
# De agent gebruikt alleen de standaardbibliotheek, inclusief ssl; dat zit in het
# python3-pakket van alpine.
RUN apk add --no-cache python3
COPY tools/electrum-gate/nginx.conf /etc/nginx/nginx.conf
# Met .in erachter, want dit is nog niet de configuratie die nginx leest: het
# backend-adres komt uit de omgeving en entrypoint.sh vult het bij het starten in.
COPY tools/electrum-gate/stream.conf /etc/nginx/stream.conf.in
COPY tools/electrum-gate/index.html /usr/share/nginx/html/index.html
COPY whatsnext-electrum-gate/icon.png /usr/share/nginx/html/icon.png
COPY tools/electrum-gate/agent.py /app/agent.py
COPY tools/electrum-gate/entrypoint.sh /entrypoint.sh
# De uitvoerbit hier zetten en niet vertrouwen op wat git meegeeft: de repo wordt
# ook op Windows bewerkt en daar bestaat die bit niet.
RUN chmod 0755 /entrypoint.sh
ENV PYTHONUNBUFFERED=1
EXPOSE 80 50022
# De nginx-image heeft een eigen entrypoint dat scripts uit /docker-entrypoint.d
# draait. Die doen hier niets nuttigs (envsubst op een templates-map die leeg is)
# en één ervan zou, als hij ooit wél iets deed, precies de fout maken waar dit
# project al een dag aan kwijt was. Weg ermee: het commando is het commando.
ENTRYPOINT []
CMD ["/entrypoint.sh"]
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# ═══════════════════════════════════════════════════════════════════════════════
# Bouwt de image voor de app whatsnext-electrum-gate.
#
# Eén image voor de twee containers van de app: nginx met de pagina en het
# stream-blok, en python3 met de agent. Wat erin gaat staat in de Dockerfile
# hiernaast; de bestanden zelf staan in deze map, behalve icon.png, dat in de
# app-map blijft omdat het manifest ernaar wijst.
#
# 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/electrum-gate/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/electrum-gate/build.sh
# Dat vraagt buildx met QEMU en duwt meteen naar het register, want een
# multi-arch-manifest bestaat niet lokaal. Nog nooit geprobeerd; zie het plan
# Eigenimage, PLAN.md §4. Zonder PLATFORMS bouwt dit één architectuur, die van
# de machine waarop je staat, en dat is voor eigen gebruik op een amd64-Umbrel
# genoeg.
# ═══════════════════════════════════════════════════════════════════════════════
set -eu
# ── De pin ────────────────────────────────────────────────────────────────────
# 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 (twee keer: 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 de eigen image raakt vrijwel elke wijziging de image, dus ze lopen
# meestal gelijk; de regel blijft: verhoog je VERSION, dan ook het manifest.
VERSION="0.1.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
# hem onbereikbaar en de installatie faalt met "pull access denied". Dat is op
# 25-08-2026 bij Evolu Relay op het apparaat vastgesteld.
IMAGE="sc.kamenier-hamer.nl/sysop/electrum-gate:${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 entrypoint.sh agent.py nginx.conf stream.conf index.html; do
if [ ! -f "${RECEPT}/${bestand}" ]; then
echo "FOUT: ${bestand} ontbreekt in ${RECEPT}" >&2
exit 1
fi
done
if [ ! -f "${REPO}/whatsnext-electrum-gate/icon.png" ]; then
echo "FOUT: whatsnext-electrum-gate/icon.png ontbreekt in ${REPO}" >&2
exit 1
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}/stream.conf" "${RECEPT}/index.html" 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-electrum-gate/docker-compose.yml (twee keer, agent en server), en"
echo "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}"
+141
View File
@@ -0,0 +1,141 @@
#!/bin/sh
# ═══════════════════════════════════════════════════════════════════════════════
# Het startscript van de server-container van Electrum Gate.
#
# Tot 0.0.29 stond dit als inline command-blok in docker-compose.yml, omdat de
# compose in de update-whitelist van umbreld staat en een los script niet. Dat
# betekende shell in een YAML-string, met elk dollarteken verdubbeld omdat
# umbreld anders elke ${...} leegmaakt. Sinds de eigen image is het een gewoon
# bestand; wat het doet is niet veranderd.
#
# Drie dingen die hier gebeuren en waarom ze hier gebeuren en niet in de agent:
#
# 1. de TLS-poort aan- of uitzetten naar de toestand van cert.conf. nginx weigert
# te starten met een 'listen ssl' zonder certificaat, en de pagina waarop je
# dat certificaat kiest moet juist altijd omhoog komen;
# 2. de open verbindingen tellen. Dat kan alleen in deze netwerk-namespace;
# 3. nginx herladen als de agent een vlag neerzet. De agent kan dat niet zelf,
# want dat zou de Docker-socket vragen en die is er bewust uit.
# ═══════════════════════════════════════════════════════════════════════════════
set -eu
STATE=/var/lib/gate
mkdir -p "$STATE" "$STATE/tls"
# ── Het stream-blok invullen ──────────────────────────────────────────────────
# Het backend-adres komt uit de omgeving, dezelfde twee variabelen die de agent
# krijgt. Tot 0.0.29 vulde umbreld ze in via stream.conf.template; nu de
# configuratie in de image zit, gebeurt dat hier. nginx kan in een proxy_pass van
# het stream-blok zelf geen omgevingsvariabele lezen.
#
# Twee plaatshouders zonder dollarteken, zodat het bestand zelf nooit door een
# envsubst-stap beschadigd kan worden, ook niet als iemand het ooit weer als
# template in de app-map zet.
if [ -n "${GATE_ELECTRUM_HOST:-}" ] && [ -n "${GATE_ELECTRUM_PORT:-}" ]; then
sed -e "s|__GATE_ELECTRUM_HOST__|${GATE_ELECTRUM_HOST}|g" \
-e "s|__GATE_ELECTRUM_PORT__|${GATE_ELECTRUM_PORT}|g" \
/etc/nginx/stream.conf.in > /etc/nginx/stream.conf
else
# Zonder backend is er niets om naar door te sturen. De pagina komt wel omhoog
# en de agent meldt daar dat het adres ontbreekt; TLS blijft uit.
rm -f /etc/nginx/stream.conf
echo "GATE_ELECTRUM_HOST of GATE_ELECTRUM_PORT ontbreekt; TLS blijft uit, de pagina werkt."
fi
# ── De TLS-poort aan of uit ───────────────────────────────────────────────────
# Hier zat tot 0.0.3 een lus die wachtte tot de agent een certificaat gekozen had,
# en dat was fout: nginx startte dan niet, dus de pagina kwam niet omhoog, en de
# pagina is juist waar je dat certificaat kiest.
#
# Nu start nginx altijd. Het stream-blok komt erbij zodra cert.conf er is, en
# verdwijnt weer als de agent zijn keuze intrekt. nginx.conf haalt de map tls/ op
# met een jokerteken; die matcht dan niets en dat is geen fout.
#
# Deze functie eindigt altijd geslaagd, en dat is met opzet: het script draait
# onder 'set -e', dus een mislukte cp zou de aanroeper afbreken. In de lus
# hieronder is die aanroeper de wachtlus, en die mag om geen enkele reden stoppen.
sync_tls() {
if [ -f "$STATE/cert.conf" ] && [ -f /etc/nginx/stream.conf ]; then
# Kopiëren en niet linken: nginx opent dit pad als de gebruiker nginx, en een
# symlink is hier nodeloos fragiel.
if cp /etc/nginx/stream.conf "$STATE/tls/stream.conf"; then
echo "Certificaat aanwezig, TLS luistert op 50022."
else
echo "stream.conf kon niet worden weggezet; TLS blijft uit."
fi
else
rm -f "$STATE/tls/stream.conf"
echo "Nog geen certificaat gekozen; TLS staat uit, de pagina werkt."
fi
}
sync_tls
# ── De verbindingsteller ──────────────────────────────────────────────────────
# nginx stream schrijft zijn logregel pas bij het sluiten van een sessie, en een
# wallet houdt zijn verbinding uren open; zonder deze teller lijkt een actieve
# wallet dus afwezig. Dat was de vraag van de gebruiker op 20-08-2026.
#
# /proc/net/tcp geldt per netwerk-namespace, dus dit ziet alleen de sockets van
# deze container, en daarom staat de teller hier en niet in de agent: die zit in
# een andere namespace en kan er niet bij. Kolom 2 is het lokale adres met de
# poort in hex, kolom 4 de toestand. C366 is 50022 en 01 is ESTABLISHED, dus de
# luisterende socket (0A) en de verbindingen naar de backend en naar de pagina
# vallen er buiten.
#
# Geteld wordt alleen het aantal. Geen adres en geen bronpoort, net als in het
# log_format in stream.conf.
count_sessions() {
cat /proc/net/tcp /proc/net/tcp6 2>/dev/null \
| awk '$4 == "01" && $2 ~ /:C366$/ { n++ } END { print n+0 }'
}
# Meteen een 0 neerzetten, zodat de pagina "geen verbindingen" kan tonen in
# plaats van "onbekend" in het minuutje voor de eerste ronde.
count_sessions > "$STATE/sessions"
# ── De herlaadlus ─────────────────────────────────────────────────────────────
# Draait op de achtergrond; nginx wordt hieronder met exec het hoofdproces.
# Andersom gaat twee keer mis: de shell blijft dan PID 1 en geeft signalen niet
# door, en als nginx omvalt eindigt het script met exitcode 0, waardoor Docker een
# geslaagde afsluiting ziet en 'restart: on-failure' niet ingrijpt.
reload_nginx() {
# Een certificaat dat nginx niet aanneemt laat de reload falen. Dat mag niet
# met 'set -e' meegaan, want dan verdwijnt de lus en wordt geen enkele latere
# wijziging meer opgepikt, zonder dat er iets te zien is.
if ! nginx -s reload; then
echo "Herladen mislukt; nginx houdt de vorige configuratie."
fi
}
(
while true; do
sleep 10
# Eerst schrijven, dan mv: de agent leest dit bestand op zijn eigen moment en
# mag geen half bestand zien.
count_sessions > "$STATE/sessions.tmp"
mv "$STATE/sessions.tmp" "$STATE/sessions"
if [ -f "$STATE/reload" ]; then
echo "Certificaat gewijzigd, nginx herladen ..."
# Eerst wegzetten, dan herladen. Andersom zou een herlading die mislukt de
# vlag toch opruimen, en dan probeert hij het nooit meer.
mv "$STATE/reload" "$STATE/reload.done"
# Voor de herlading, niet erna: een reload leest de configuratie opnieuw,
# dus het stream-blok moet er dan al liggen.
sync_tls
reload_nginx
elif [ -f "$STATE/cert.conf" ] && [ ! -f "$STATE/tls/stream.conf" ]; then
# De vangnetregel. De agent zet de vlag bij elke wijziging, dus normaal komt
# hier niets langs; wel als de vlag verloren gaat of als cert.conf van een
# vorige installatie al klaarlag. Zonder deze tak zou TLS dan uit blijven
# staan tot de volgende wijziging.
echo "Certificaat gevonden zonder herlaadvlag, TLS aanzetten ..."
sync_tls
reload_nginx
fi
done
) &
exec nginx -g 'daemon off;'
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
# nginx-configuratie voor Electrum Gate.
#
# Dit bestand zit in de image (tools/electrum-gate/Dockerfile) als
# /etc/nginx/nginx.conf. Tot 0.0.29 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 voor http nog steeds uit staat is een keuze en geen restant: de
# pagina hangt achter de app_proxy en wat die opvraagt is niet interessant.
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
# ── De web-UI ────────────────────────────────────────────────────────────────
# Wordt niet rechtstreeks gepubliceerd: umbrelOS zet er zijn eigen app_proxy
# voor, en die verwijst naar poort 80 van deze container.
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;
# status.json wordt door de agent in de gedeelde map geschreven, niet in
# de webroot. Geen cache: de pagina vraagt hem juist op om te zien hoe
# oud de gegevens zijn.
location = /status.json {
alias /var/lib/gate/status.json;
add_header Cache-Control "no-store";
}
# De API van de agent. Het enige pad waarlangs iets van buiten de
# instellingen van de app raakt, en dat is de certificaatkeuze.
#
# Waarom dit te verantwoorden is op een app waarvan de kleinheid het punt
# is: het pad hangt achter de app_proxy van umbrelOS, die er zijn eigen
# inlog voor zet, de TLS-poort staat er los van, en de agent accepteert
# alleen een certificaat-id die hij zelf in de gemounte mappen gevonden
# heeft. Het ergste wat een geslaagde aanroep doet is een ander, ook
# bestaand, certificaat kiezen.
#
# Geen proxy_set_header hier: de agent heeft ze niet nodig.
#
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
# tot 0.0.23 wel, en het werkte zolang deze app de enige was met een
# service van die naam. Sinds Evolu Relay er is, is dat niet meer waar:
# beide apps zitten op hetzelfde Docker-netwerk, allebei met een 'agent'
# op poort 8000, en Docker verdeelt de naam dan over de twee. Op
# 28-08-2026 gemeten op het apparaat: van tien verzoeken kwamen er vijf
# bij de verkeerde app uit. De vorm <app-id>_<service>_1 is uniek per app;
# het is dezelfde les die in de compose al stond over de naam 'server'.
location /api/ {
proxy_pass http://whatsnext-electrum-gate_agent_1:8000;
client_max_body_size 1k;
}
# Het uploaden van een certificaat, en het enige pad met een grotere
# limiet: een sleutel plus keten haalt de 1k van hierboven ruim. Een
# exacte match, dus deze regel wint van /api/ hierboven en de grotere
# limiet geldt nergens anders. De agent kapt zelf ook af, op 96k; deze
# limiet is de eerste zeef en niet de enige.
location = /api/certificate/upload {
proxy_pass http://whatsnext-electrum-gate_agent_1:8000;
client_max_body_size 96k;
}
}
}
# ── De TLS-terminatie ────────────────────────────────────────────────────────
# Die staat niet hier maar in stream.conf, en dat is geen ordening maar de
# reparatie van een klem. nginx weigert te starten als een 'listen ... ssl' geen
# certificaat heeft; stond het stream-blok hier, dan kwam met dat blok ook de
# pagina hierboven niet omhoog zolang de agent geen certificaat gekozen had. En
# de pagina is juist de plek waar die keuze gemaakt wordt.
#
# entrypoint.sh legt stream.conf in de map hieronder zodra cert.conf bestaat, en
# haalt het weg als dat niet zo is. Een include met een jokerteken die nergens op
# uitkomt is voor nginx geen fout, dus zonder certificaat start deze server
# zonder poort 50022 en met een pagina die vertelt waarom.
include /var/lib/gate/tls/*.conf;
+65
View File
@@ -0,0 +1,65 @@
# De TLS-terminatie van Electrum Gate, als los bestand, en dat is de hele truc.
#
# nginx weigert te starten als een 'listen ... ssl' geen certificaat heeft, en
# welk certificaat dat wordt weet alleen de agent. Zolang dit blok in nginx.conf
# stond, kwam de server dus niet omhoog voordat er een keuze lag, en daarmee ook
# de web-UI niet: precies de pagina waarop die keuze gemaakt wordt. Een gebruiker
# zonder Zoraxy, of met twee kandidaten, zag daardoor helemaal niets en had geen
# enkele weg vooruit. Zo aangetroffen in 0.0.3.
#
# Nu staat het blok hier, en entrypoint.sh zet dit bestand pas in
# /var/lib/gate/tls/ zodra de agent cert.conf geschreven heeft. nginx.conf haalt
# die map op met een jokerteken, en een jokerteken dat niets matcht is voor nginx
# geen fout. Geen certificaat betekent dus: geen stream-blok, geen poort 50022,
# maar wel een werkende pagina die vertelt wat eraan mankeert.
#
# Dit bestand zit in de image als /etc/nginx/stream.conf.in. De twee plaatshouders
# __GATE_ELECTRUM_HOST__ en __GATE_ELECTRUM_PORT__ vult entrypoint.sh bij het
# starten in uit de omgeving; nginx kan in een proxy_pass van het stream-blok zelf
# geen omgevingsvariabele lezen. Tot 0.0.29 was dit een *.template in de app-map
# en deed umbreld die invulling, en daarom mocht er toen geen nginx-variabele met
# een dollarteken in staan. Die beperking is weg: het log_format staat nu gewoon
# hier en niet meer in een los bestand dat het command-blok wegschreef.
stream {
# Let op wat er NIET in staat: geen client-adres en geen bronpoort. Dat is
# precies het soort gegeven dat deze app van het netwerk af houdt, en om te
# zien dat het werkt is het niet nodig.
log_format gate '$time_iso8601 $status $bytes_received $bytes_sent $session_time';
access_log /var/lib/gate/stream.log gate;
server {
# 50022 en niet 50002; de reden staat bij de ports-regel in
# docker-compose.yml. Wijzig je dit, wijzig dan ook de hex-poort in de
# verbindingsteller van entrypoint.sh.
listen 50022 ssl;
# ssl_certificate en ssl_certificate_key, geschreven door de agent op
# grond van wat er in de certificaatmappen staat en wat de gebruiker op
# het dashboard gekozen heeft. Daarom staat er hier geen domeinnaam meer
# in dit bestand. Dat dit bestand bestaat betekent dat cert.conf er is:
# entrypoint.sh zet het er pas dan neer.
#
# nginx wil de VOLLEDIGE keten in ssl_certificate, dus het bestand van
# Zoraxy kan er ongewijzigd in. Splitsen in een servercertificaat en een
# CA-deel, zoals de oude stunnel-opzet deed, is hier juist fout.
include /var/lib/gate/cert.conf;
ssl_protocols TLSv1.2 TLSv1.3;
# Een Electrum-wallet houdt een langlopende verbinding open die het
# grootste deel van de tijd stil is. De standaardwaarde van
# proxy_timeout is tien minuten, en die verbreekt zo'n verbinding dus
# zonder aanleiding; de wallet merkt dat pas als hij iets wil en meldt
# dan dat er geen data is. stunnel hanteerde hier twaalf uur, en dat
# verschil is bij de overstap naar nginx over het hoofd gezien.
proxy_timeout 12h;
proxy_connect_timeout 10s;
# Houdt de verbinding naar de backend levend en laat een verbroken
# verbinding sneller opvallen dan pas bij het volgende verzoek.
proxy_socket_keepalive on;
proxy_pass __GATE_ELECTRUM_HOST__:__GATE_ELECTRUM_PORT__;
}
}
+67
View File
@@ -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"]
+438
View File
@@ -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()
+126
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
# nginx-configuratie voor Evolu Relay.
#
# Dit bestand serveert alleen de statuspagina. De relay zelf zit er niet achter:
# die publiceert zijn eigen poort in docker-compose.yml, waar Zoraxy met TLS
# naartoe wijst. Dat is de omkering ten opzichte van het pakket van 25-08-2026,
# waar de relay achter de app-proxy stond en de inlog dus uit moest; zie het plan
# Umbrelapp, PLAN.md §4h.
#
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als
# /etc/nginx/nginx.conf. Tot 0.5.6 was het een *.template in de app-map die
# umbreld bij elke start door envsubst haalde, en daarom mocht er toen geen
# nginx-variabele met een dollarteken in staan. Die beperking is weg. Dat de
# access_log nog steeds uit staat is een keuze en geen restant: de pagina hangt
# achter de app_proxy en wat die opvraagt is niet interessant.
# www-data en niet nginx: deze nginx komt uit Debian (de image is node:24-slim
# met nginx uit apt), en Debian heeft geen gebruiker nginx. Bij Electrum Gate
# staat hier wél nginx, want die image is de officiële nginx-image.
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log off;
sendfile on;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# De API van de agent: de status lezen en een opdracht in de postbus
# leggen. Dit is het enige pad waarlangs iets van buiten de instellingen
# van de app raakt.
#
# Waarom dat hier te verantwoorden is: dit pad hangt achter de app_proxy
# van umbrelOS, die er zijn eigen inlog voor zet. De relay staat op een
# andere poort en is hierlangs niet te bereiken. En het ergste wat een
# geslaagde aanroep doet is de leerstand omzetten of een eigenaar
# blokkeren, en dat is precies waar de pagina voor is.
#
# Geen proxy_set_header: de agent heeft ze niet nodig.
#
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
# eerst, en op het apparaat gaf het een 404 bij ongeveer elke tweede
# aanroep (28-08-2026): Electrum Gate heeft óók een service 'agent' op
# poort 8000, beide apps zitten op hetzelfde Docker-netwerk, en de korte
# naam kan dus naar de verkeerde container wijzen. De vorm
# <app-id>_<service>_1 is uniek per app. Dezelfde les stond al in de
# compose van Electrum Gate, over de servicenaam 'server'.
location /api/ {
proxy_pass http://whatsnext-evolu-relay_agent_1:8000;
# Een opdracht is een handvol bytes. De agent kapt zelf ook af, op
# 4k; dit is de eerste zeef en niet de enige.
client_max_body_size 4k;
}
}
}
+17
View File
@@ -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"
}
}
+173
View File
@@ -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;
},
}),
);
+239
View File
@@ -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');
}
};
+107
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
# Kopieer naar `.env` op de Umbrel en vul het token in uit Gitea:
# repo → Settings → Actions → Runners → Create new runner.
# Het token is eenmalig en wordt bij de eerste start verbruikt.
GITEA_RUNNER_REGISTRATION_TOKEN=
+40
View File
@@ -0,0 +1,40 @@
# ═══════════════════════════════════════════════════════════════════════════════
# Gitea Actions-runner voor de Umbrel: bouwt en duwt de image van Atreides
# Assets als daar een tag gezet wordt. De workflow zelf staat in die repo
# (`.gitea/workflows/release.yml`); hier staat alleen wát hem uitvoert.
#
# GEEN UMBREL-APP, en daarom staat dit onder `tools/` en niet in een app-map.
# umbrelOS weet hier niets van: dit is gereedschap op het apparaat, net als de
# bouwscripts van de andere twee apps.
#
# DE DOCKER-SOCKET ZIT ER WÉL IN, en dat is geen vergissing tegen de regel bij
# Electrum Gate ("geen docker-socket"). Die regel gaat over een app die door
# anderen geïnstalleerd wordt; dit is een bouwmachine en zonder de socket bouwt
# hij niets. Besef wat het betekent en besef het één keer goed: een runner aan
# die socket is in de praktijk root op dit apparaat, en hij voert uit wat er in
# de repo staat. Laat hem dus geen andermans repo's bouwen.
# ═══════════════════════════════════════════════════════════════════════════════
services:
runner:
# Gepind op wat er bij de eerste registratie draaide (18-09-2026). Een runner
# die stilzwijgend meeschuift met upstream is precies het soort verrassing dat
# een release-dag kost.
image: gitea/act_runner:0.6.1
container_name: gitea-act-runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://sc.kamenier-hamer.nl
# Het registratietoken komt uit Gitea (repo → Settings → Actions → Runners)
# en staat in `.env` ernaast, nooit in de repo. Na de eerste start is het
# verbruikt: de registratie zelf ligt dan in `data/.runner`.
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:?zet hem in .env}
GITEA_RUNNER_NAME: umbrel
# HET LABEL LINKS MOET GELIJK ZIJN AAN `runs-on:` IN DE WORKFLOW. Staat daar
# een label dat niemand draagt, dan wacht die workflow zonder te zeggen
# waarop. Rechts staat de image waarin een job draait; die moet de docker-CLI
# én buildx hebben, want de workflow bouwt en duwt.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://catthehacker/ubuntu:act-latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
+31
View File
@@ -0,0 +1,31 @@
# De bronbestanden van de app-iconen
Hier hoort het werkbestand waaruit `icon.png` van een app komt: de gelaagde versie waarin je nog
kunt bewerken. Eén bestand per app, met de app-id als naam.
**Op dit moment staat er niets.** Beide iconen zijn buiten deze repo gemaakt. Op 28-08-2026 begon de
gebruiker aan een 3D-icoon voor Evolu Relay in Paint.NET; dat was op 30-08-2026 nog niet mooi genoeg
en is door hem weggehaald. Komt er een volgende poging, dan is dít de map.
## Waarom hier en niet in de app-map
**umbreld kopieert bij een installatie de héle app-map naar het apparaat**, met `rsync --archive`
naar `~/umbrel/app-data/<app-id>/`. Alles wat daar staat belandt dus op de Umbrel én in de back-up.
Voor een gelaagd bewerkbestand is dat allebei zinloos: het apparaat kan er niets mee en de back-up
wordt er alleen groter van.
Dat de app-map niets mag bevatten dat umbreld niet nodig heeft, controleert
`tests/test_appstore_vorm.py` sinds 30-08-2026. Kwam je hier omdat die toets over een bestand van
jou klaagde: dit is de plek waar het hoort.
## Wat er wél in de app-map staat
Alleen het resultaat: `icon.png`. Dat bestand staat **niet** in de whitelist die umbreld bij een
update ververst, dus een nieuw icoon bereikt een bestaande installatie niet. Twee dingen volgen
daaruit, en de tweede wordt makkelijk vergeten:
- het manifest verwijst met een `icon:`-regel naar de rauwe URL in deze repo, en dát is wat het
dashboard van umbrelOS toont. Een nieuw icoon is daar dus meteen zichtbaar;
- de kop van de statuspagina laadt `icon.png` uit de gemounte app-map, en die blijft na een update
het oude plaatje tonen tot de app opnieuw geïnstalleerd wordt. Reken er niet op dat een nieuw
icoon overal in één keer doorkomt.
+27
View File
@@ -0,0 +1,27 @@
# Instellingen van de testclient. Kopieer dit bestand naar .env en vul het in;
# .env staat in .gitignore en komt dus niet in de repo.
#
# Het adres van je Umbrel hoort hier en nergens anders: dit is een publieke app
# store, en een adres in de historie krijg je er niet meer uit.
# Waar de relay luistert. Poort 3852 is wat de app publiceert.
# Voorbeelden:
# RELAY_URL=ws://umbrel.local:3852
# RELAY_URL=ws://192.168.1.20:3852
# RELAY_URL=wss://relay.voorbeeld.nl (achter een reverse proxy met TLS)
#
# ws:// of wss:// mag je weglaten; zonder schema wordt het ws://.
#
# LET OP, en dit kostte op 09-09-2026 de eerste poging: zet bij wss:// GEEN
# poort 3852 erachter. Die poort is waar de relay zelf luistert, in plat ws,
# binnen je netwerk. Je certificaat zit op de reverse proxy en die luistert op
# 443. wss://host:3852 vraagt dus om TLS op een poort waar geen TLS is, en van
# buiten is die poort bovendien dicht. Laat de poort weg.
RELAY_URL=ws://umbrel.local:3852
# Waar het register en de databases komen te staan. Relatief aan
# tools/relay-client/. Hier staan mnemonics, dus deze map is gitignored.
#DATA_DIR=data
# De poort van het bedieningsvlak in de browser.
#UI_PORT=4380
+55
View File
@@ -0,0 +1,55 @@
@echo off
rem ============================================================================
rem Start het bedieningsvlak van de testclient en opent de browser.
rem
rem Dubbelklikken werkt: het venster blijft open zolang de server draait, en
rem blijft ook open als er iets misgaat, want anders lees je de fout nooit.
rem
rem De macOS-tegenhanger is Start.command ernaast.
rem ============================================================================
setlocal
cd /d "%~dp0"
where node >nul 2>nul
if errorlevel 1 (
echo Node is niet gevonden. Deze client heeft Node 24 of hoger nodig.
echo Zie https://nodejs.org
echo.
pause
exit /b 1
)
if not exist "node_modules" (
echo Pakketten ontbreken, npm install wordt gedraaid...
call npm install
if errorlevel 1 (
echo.
echo npm install is mislukt.
pause
exit /b 1
)
echo.
)
if not exist ".env" (
echo Let op: er is geen .env, dus er is geen relay-adres.
echo Kopieer .env.sample naar .env en vul RELAY_URL in.
echo Zonder dat werkt alles behalve verbinden met de relay.
echo.
)
rem De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem
rem dan in .env als UI_PORT en niet hier: dan weet de client het ook.
set UI_ADRES=http://127.0.0.1:4380
rem De browser gaat nu open, terwijl de server nog moet starten. Dat is goed:
rem het tabblad ververst zichzelf elke drie seconden, dus een eerste poging die
rem te vroeg komt herstelt vanzelf.
start "" "%UI_ADRES%"
node src\cli.js ui
echo.
echo De client is gestopt.
pause
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# =============================================================================
# Start het bedieningsvlak van de testclient en opent de browser. Voor macOS.
#
# Dubbelklikken in Finder werkt, maar alleen als het bestand uitvoerbaar is.
# Git bewaart dat bit, dus na een verse kloon hoort het te kloppen; is dat niet
# zo, dan eenmalig: chmod +x Start.command
#
# De Windows-tegenhanger is Start.bat ernaast.
# =============================================================================
set -u
cd "$(dirname "$0")"
if ! command -v node >/dev/null 2>&1; then
echo "Node is niet gevonden. Deze client heeft Node 24 of hoger nodig."
echo "Zie https://nodejs.org"
echo
read -r -p "Enter om te sluiten."
exit 1
fi
if [ ! -d node_modules ]; then
echo "Pakketten ontbreken, npm install wordt gedraaid..."
if ! npm install; then
echo
echo "npm install is mislukt."
read -r -p "Enter om te sluiten."
exit 1
fi
echo
fi
if [ ! -f .env ]; then
echo "Let op: er is geen .env, dus er is geen relay-adres."
echo "Kopieer .env.sample naar .env en vul RELAY_URL in."
echo "Zonder dat werkt alles behalve verbinden met de relay."
echo
fi
# De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem dan
# in .env als UI_PORT en niet hier: dan weet de client het ook.
UI_ADRES="http://127.0.0.1:4380"
# De browser gaat nu open, terwijl de server nog moet starten. Dat is goed: het
# tabblad ververst zichzelf elke drie seconden, dus een eerste poging die te
# vroeg komt herstelt vanzelf.
open "$UI_ADRES" >/dev/null 2>&1 || true
node src/cli.js ui
echo
echo "De client is gestopt."
read -r -p "Enter om te sluiten."
+616
View File
@@ -0,0 +1,616 @@
{
"name": "whatsnext-relay-client",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "whatsnext-relay-client",
"version": "0.0.0",
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@evolu/common": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@evolu/common/-/common-8.7.0.tgz",
"integrity": "sha512-52bfvAJZaOmAAmiaOdkGn4MtYbIK1xf5sSb9fjzdRRAD6XbJtRT4yqlJ5MxBacsUHDtMXjUW02XgYlbUXyJ04A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@noble/ciphers": "^2.3.0",
"@noble/hashes": "^2.3.0",
"@scure/bip39": "^2.3.0",
"@standard-schema/spec": "^1.1.0",
"kysely": "^0.29.5",
"random": "5.5.1"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/@evolu/nodejs": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@evolu/nodejs/-/nodejs-3.1.0.tgz",
"integrity": "sha512-xw1eYjsWNbQfJsDQ0pTe/2iYrxuzDG0cHMhObyCbHwICdJzXvEH2iVGzw4qPfxW4fikZNcUTsAHyzblW8tJkqg==",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.11.1",
"ws": "^8.21.3"
},
"engines": {
"node": ">=24.0.0"
},
"peerDependencies": {
"@evolu/common": "^8.0.0",
"@evolu/oxlint-config": "^0.1.0",
"@evolu/typescript-config": "^0.1.0",
"oxlint": "^1.79.0",
"oxlint-tsgolint": "7.0.2001",
"typescript": ">=7.0.0",
"vite": ">=8.0.0",
"webpack": ">=5.108.4"
},
"peerDependenciesMeta": {
"@evolu/oxlint-config": {
"optional": true
},
"@evolu/typescript-config": {
"optional": true
},
"oxlint": {
"optional": true
},
"oxlint-tsgolint": {
"optional": true
},
"typescript": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz",
"integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/kysely": {
"version": "0.29.5",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz",
"integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.96.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/random": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/random/-/random-5.5.1.tgz",
"integrity": "sha512-2TIJKf/BGjy0FGeZaan2+a/vq37afbSyscquADMFC13KBjFmg+10T/TkACpP9GMHfbEruKYbHqq0rjCA9x8gNA==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "whatsnext-relay-client",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Testcliënt voor Evolu Relay: meerdere eigenaars, elk met eigen sleutels, om het toegangsbeleid en de opslag te beproeven.",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/cli.js"
},
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De opdrachtregel lezen. Meer niet.
//
// Apart van cli.js omdat dit het enige deel van de opdrachtregel is dat te
// toetsen valt zonder iets te starten. Dezelfde scheiding als policy.js aan de
// relay-kant: pure functies aan de ene kant, alles wat schijf of netwerk raakt
// aan de andere.
// ═══════════════════════════════════════════════════════════════════════════════
export const OPDRACHTEN = {
nieuw: { argumenten: ['naam'], uitleg: 'Maakt een nieuwe eigenaar met verse sleutels' },
lijst: { argumenten: [], uitleg: 'Toont de eigenaars in het register' },
schrijf: { argumenten: ['naam', 'label'], uitleg: 'Schrijft een blob weg voor deze eigenaar' },
lees: { argumenten: ['naam'], uitleg: 'Toont de blobs van deze eigenaar' },
test: { argumenten: ['naam'], uitleg: 'Test of de relay deze eigenaar toelaat, zonder te verbinden' },
spiegel: { argumenten: ['naam'], uitleg: 'Haalt deze eigenaar op in een lege database, via de relay' },
sync: { argumenten: ['naam'], uitleg: 'Verbindt en blijft draaien tot Ctrl-C' },
vergeet: { argumenten: ['naam'], uitleg: 'Haalt een eigenaar uit het register' },
ui: { argumenten: [], uitleg: 'Start het bedieningsvlak in de browser' },
};
/**
* Leest `process.argv.slice(2)`.
*
* Geeft `{fout}` in plaats van te gooien: de aanroeper drukt dat af met de
* gebruiksaanwijzing eronder, en dat leest beter dan een stack trace.
*
* Vlaggen zijn `--naam waarde` of `--naam=waarde`. Een losse `--naam` is `true`.
*/
export const leesArgumenten = (argv) => {
const losse = [];
const vlaggen = {};
for (let i = 0; i < argv.length; i += 1) {
const stuk = argv[i];
if (!stuk.startsWith('--')) {
losse.push(stuk);
continue;
}
const kaal = stuk.slice(2);
const isGelijk = kaal.indexOf('=');
if (isGelijk > 0) {
vlaggen[kaal.slice(0, isGelijk)] = kaal.slice(isGelijk + 1);
continue;
}
const volgende = argv[i + 1];
if (volgende !== undefined && !volgende.startsWith('--')) {
vlaggen[kaal] = volgende;
i += 1;
} else {
vlaggen[kaal] = true;
}
}
const [opdracht, ...rest] = losse;
if (opdracht === undefined) return { fout: null, opdracht: null, waarden: {}, vlaggen };
const vorm = OPDRACHTEN[opdracht];
if (!vorm) return { fout: `Onbekende opdracht "${opdracht}".`, opdracht: null, waarden: {}, vlaggen };
if (rest.length < vorm.argumenten.length) {
const ontbreekt = vorm.argumenten.slice(rest.length).join(', ');
return { fout: `"${opdracht}" mist: ${ontbreekt}.`, opdracht, waarden: {}, vlaggen };
}
if (rest.length > vorm.argumenten.length) {
// Streng, en met opzet. `schrijf piet mijn label` ziet er goed uit en zou
// anders stil "mijn" als label nemen; dan sta je te kijken naar een blob die
// anders heet dan je typte.
return {
fout: `"${opdracht}" verwacht ${vorm.argumenten.length} argument(en), niet ${rest.length}. Gebruik aanhalingstekens om een label met spaties.`,
opdracht,
waarden: {},
vlaggen,
};
}
const waarden = {};
vorm.argumenten.forEach((naam, plek) => {
waarden[naam] = rest[plek];
});
return { fout: null, opdracht, waarden, vlaggen };
};
export const gebruiksaanwijzing = () => {
const regels = ['Gebruik: node src/cli.js <opdracht> [argumenten]', ''];
for (const [naam, vorm] of Object.entries(OPDRACHTEN)) {
const argumenten = vorm.argumenten.map((a) => `<${a}>`).join(' ');
regels.push(` ${`${naam} ${argumenten}`.trim().padEnd(24)}${vorm.uitleg}`);
}
regels.push('');
regels.push('Vlaggen:');
regels.push(' --bytes <n> Omvang van de blob bij `schrijf` (standaard 64)');
regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`');
regels.push(' --wacht <n> Seconden wachten op de relay, bij `spiegel` (standaard 15)');
regels.push(' --poort <n> Poort van het bedieningsvlak bij `ui`');
regels.push('');
regels.push('Het adres van de relay komt uit .env; zie .env.sample.');
return regels.join('\n');
};
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
// ═══════════════════════════════════════════════════════════════════════════════
// De opdrachtregel van de testcliënt.
//
// Alles wat hier gebeurt kan ook in het bedieningsvlak (`ui`), en dat is de
// bedoeling: dit is dezelfde laag, alleen zonder browser. Handig om een proef te
// scripten en om te zien wat er misgaat als de pagina niet meewerkt.
//
// De opdrachten staan in argumenten.js, het register in register.js, en het
// praten met Evolu in client.js. Hier staat alleen de lijm plus wat er op het
// scherm komt.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { gebruiksaanwijzing, leesArgumenten } from './argumenten.js';
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
import { leesConfig } from './config.js';
import { klopAan } from './probe.js';
import { openRegister } from './register.js';
const config = leesConfig();
const register = openRegister(config.dataDir);
const databasesIn = join(config.dataDir, 'db');
/** Zoekt een eigenaar op of stopt met een nette melding. */
const haalEigenaarOp = (naam) => {
const regel = register.zoek(naam);
if (!regel) {
console.error(`Geen eigenaar "${naam}". \`lijst\` toont wie er wel zijn.`);
process.exit(1);
}
return { regel, owner: eigenaarUitMnemonic(regel.mnemonic) };
};
/** Opent de opslag van een eigenaar, met of zonder relay. */
const openVoor = (regel, { metRelay }) =>
openStore({
directory: databasesIn,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: metRelay ? config.relayUrl : null,
// Een defect in een worker betekent dat deze instantie stuk is, en de
// opdracht erna wacht dan op een antwoord dat nooit komt. Afkappen is hier
// dus vriendelijker dan doordraaien: een opdrachtregel die blijft hangen
// zegt niets, een foutmelding wel.
onDefect: (defect) => {
console.error('Defect uit een worker, de opdracht wordt afgebroken:');
console.error(defect);
process.exit(1);
},
// Wat Evolu wérkelijk opent, en niet wat wij hem meegaven. De `OwnerId` in
// die URL is wat de relay ziet en dus wie er toegelaten of geweigerd wordt.
onWebSocket: (m) => {
console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`);
},
});
const eisRelay = () => {
if (config.relayUrl) return;
console.error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
process.exit(1);
};
const opdrachten = {
nieuw: async ({ naam }) => {
const owner = nieuweEigenaar();
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
console.log(`Eigenaar "${regel.naam}" aangemaakt.`);
console.log(` OwnerId ${regel.ownerId}`);
console.log('');
console.log('Dat OwnerId is wat je op de statuspagina van de app terugziet. De mnemonic staat');
console.log(`in ${register.pad} en wordt hier niet afgedrukt.`);
},
lijst: async () => {
const eigenaars = register.lijst();
if (eigenaars.length === 0) {
console.log('Nog geen eigenaars. `nieuw <naam>` maakt er een.');
return;
}
const breedte = Math.max(...eigenaars.map((e) => e.naam.length), 4);
console.log(`${'naam'.padEnd(breedte)} OwnerId`);
for (const eigenaar of eigenaars) {
console.log(`${eigenaar.naam.padEnd(breedte)} ${eigenaar.ownerId}`);
}
},
schrijf: async ({ naam, label }, vlaggen) => {
const { regel } = haalEigenaarOp(naam);
const bytes = Number.parseInt(vlaggen.bytes ?? '64', 10);
if (!Number.isInteger(bytes) || bytes < 1) {
console.error(`--bytes moet een positief geheel getal zijn, niet "${vlaggen.bytes}".`);
process.exit(1);
}
// Een herkenbaar patroon en geen willekeur: bij het teruglezen wil je kunnen
// zien dát het jouw bytes zijn, en niet alleen hoeveel het er waren.
const body = new Uint8Array(bytes);
for (let i = 0; i < bytes; i += 1) body[i] = i % 256;
// Standaard mét relay, want schrijven zonder verbinding zegt niets over de
// relay en dat is waarvoor dit gereedschap bestaat. Ontbreekt het adres, dan
// is dat een fout en geen stille lokale schrijfactie: anders denk je dat je
// iets beproefd hebt. Met `--lokaal` kies je er expliciet voor, en dat is
// precies wat de proef "toelaten en opnieuw verbinden" nodig heeft.
const lokaal = vlaggen.lokaal === true || vlaggen.lokaal === 'true';
if (!lokaal) eisRelay();
const store = await openVoor(regel, { metRelay: !lokaal });
try {
const geschreven = await store.schrijf(label, body);
console.log(`Geschreven: ${geschreven.id} (${bytes} bytes, label "${label}")`);
if (lokaal) {
console.log('Lokaal, dus niet naar de relay gestuurd.');
return;
}
console.log(`Verbonden met ${store.transportUrl}`);
// De synchronisatie loopt asynchroon. Even wachten is hier eerlijker dan
// meteen afsluiten en doen alsof het weg is. Wat er daarna op de relay
// staat, zegt de statuspagina van de app en niet deze uitdraai.
await new Promise((klaar) => {
setTimeout(klaar, 2000);
});
} finally {
await store.sluit();
}
},
lees: async ({ naam }) => {
const { regel } = haalEigenaarOp(naam);
const store = await openVoor(regel, { metRelay: false });
try {
const rijen = await store.lees();
if (rijen.length === 0) {
console.log(`"${naam}" heeft nog geen blobs.`);
return;
}
console.log(`${rijen.length} blob(s) voor "${naam}":`);
for (const rij of rijen) {
console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`);
}
} finally {
await store.sluit();
}
},
test: async ({ naam }) => {
eisRelay();
const { regel } = haalEigenaarOp(naam);
console.log(`Testen bij ${config.relayUrl} als ${regel.ownerId}`);
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
console.log(` status ${uitkomst.status ?? '-'}`);
console.log(` uitkomst ${uitkomst.uitleg}`);
if (!uitkomst.geaccepteerd && uitkomst.status === 401) {
console.log('');
console.log('Zet op de statuspagina van de app het venster voor nieuwe eigenaars open,');
console.log('of laat deze eigenaar daar alsnog toe.');
}
process.exit(uitkomst.geaccepteerd ? 0 : 1);
},
spiegel: async ({ naam }, vlaggen) => {
// De enige proef die van de cliëntkant kan bewijzen dat er werkelijk iets op
// de relay staat. Dezelfde eigenaar, een lege database ernaast: alles wat
// hier binnenkomt heeft de heenreis en de terugreis gemaakt.
//
// Waarom dit nodig is: de cliënt is local-first, dus `lees` toont je altijd
// je eigen rijen, ook als de relay ze nooit gezien heeft. Wat de relay
// bewaart is van hier niet te zien; dit is de omweg eromheen.
eisRelay();
const { regel } = haalEigenaarOp(naam);
const seconden = Number.parseInt(vlaggen.wacht ?? '15', 10);
const tijdelijk = mkdtempSync(join(tmpdir(), 'relay-client-spiegel-'));
console.log(`"${naam}" opnieuw opgehaald in een lege database.`);
console.log(` ${tijdelijk}`);
console.log(` ${config.relayUrl}`);
console.log('');
const store = await openStore({
directory: tijdelijk,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: config.relayUrl,
onWebSocket: (m) => { console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`); },
onDefect: (defect) => {
console.error('Defect uit een worker, de opdracht wordt afgebroken:');
console.error(defect);
process.exit(1);
},
});
try {
// Peilen tot het aantal een paar rondes stil blijft. Er is geen signaal
// "hier ben ik klaar": Evolu synchroniseert en blijft synchroniseren.
let vorige = -1;
let stil = 0;
for (let ronde = 0; ronde < seconden * 2; ronde += 1) {
await new Promise((klaar) => {
setTimeout(klaar, 500);
});
const nu = (await store.lees()).length;
if (nu === vorige && nu > 0) {
stil += 1;
if (stil >= 6) break;
} else {
stil = 0;
}
vorige = nu;
}
const rijen = await store.lees();
if (rijen.length === 0) {
console.log('Er kwam niets terug.');
console.log('');
console.log('Dat kan twee dingen betekenen en de cliënt kan ze niet uit elkaar houden:');
console.log('de relay heeft niets van deze eigenaar, of hij was er nog niet klaar mee.');
console.log('Probeer `--wacht 60` en kijk anders op de statuspagina van de app.');
return;
}
console.log(`${rijen.length} blob(s) terug van de relay:`);
for (const rij of rijen) {
console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`);
}
} finally {
await store.sluit();
// Eigen tijdelijke map, dus die ruimt deze opdracht zelf op. Er zit een
// database van een echte eigenaar in.
rmSync(tijdelijk, { recursive: true, force: true });
}
},
sync: async ({ naam }) => {
eisRelay();
const { regel } = haalEigenaarOp(naam);
const store = await openVoor(regel, { metRelay: true });
console.log(`"${naam}" verbonden met ${store.transportUrl}`);
console.log('Ctrl-C om te stoppen.');
const stop = async () => {
console.log('');
console.log('Afsluiten...');
await store.sluit();
process.exit(0);
};
process.on('SIGINT', () => {
void stop();
});
// Blijven draaien. Een lopende verbinding is precies wat de proef "blokkeren
// tijdens een lopende verbinding" nodig heeft; zie PLAN.md §4c.
await new Promise(() => {});
},
vergeet: async ({ naam }) => {
const weg = register.vergeet(naam);
console.log(`"${weg.naam}" is uit het register.`);
console.log('De lokale database blijft staan en op de relay verandert er niets.');
},
ui: async (_waarden, vlaggen) => {
const { startUi } = await import('./ui.js');
const poort = vlaggen.poort ? Number.parseInt(vlaggen.poort, 10) : config.uiPoort;
await startUi({ config: { ...config, uiPoort: poort }, register, databasesIn });
},
};
const { fout, opdracht, waarden, vlaggen } = leesArgumenten(process.argv.slice(2));
if (fout) {
console.error(fout);
console.error('');
console.error(gebruiksaanwijzing());
process.exit(1);
}
if (!opdracht) {
console.log(gebruiksaanwijzing());
process.exit(0);
}
try {
await opdrachten[opdracht](waarden, vlaggen);
} catch (oorzaak) {
console.error(oorzaak.message);
process.exit(1);
}
+258
View File
@@ -0,0 +1,258 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De testcliënt zelf: eigenaars aanmaken en blobs door de relay duwen.
//
// Dit is de publieke kant van dit gereedschap. De opdrachtregel en straks de
// pagina praten hiermee en niet rechtstreeks met Evolu; `evolu-node.js` ernaast
// doet alleen de platformafhankelijkheden en heeft geen weet van eigenaars.
//
// Eén eigenaar is één mnemonic plus één database. Evolu leidt de bestandsnaam af
// uit `appName` en de eigenaar, dus alle eigenaars kunnen in dezelfde map staan
// zonder elkaar te raken; wat ze deelt is het proces, en dat is precies wat de
// gedeelde worker aankan (zie evolu-node.js).
//
// Wat hier NIET in hoort: iets dat de gegevens van Trezor Suite probeert te
// lezen. Deze cliënt maakt eigen sleutels en leest zijn eigen gegevens; zie de
// niet-doelen in het plan Testclient.
// ═══════════════════════════════════════════════════════════════════════════════
import {
AppName,
createAppOwner,
createEvolu,
createOwnerSecret,
createOwnerWebSocketTransport,
createQueryBuilder,
createRandomBytes,
createRun,
id,
mnemonicToOwnerSecret,
NonEmptyTrimmedString100,
Uint8Array as Uint8ArrayType,
} from '@evolu/common';
import { installPolyfills } from '@evolu/common/polyfills';
import { createNodeEvoluDeps } from './evolu-node.js';
// Node 24 mist `Map.prototype.getOrInsertComputed`, dat Evolu wel gebruikt.
// Zonder deze aanroep valt niet het aanmaken om maar de eerste schrijfactie, en
// dat is precies het soort fout dat je pas op het verkeerde moment vindt. Hier en
// niet in het startbestand, zodat elke ingang van dit gereedschap hem meekrijgt.
installPolyfills();
/**
* De naam waaronder deze cliënt zijn databases wegzet.
*
* Onderdeel van de bestandsnaam, dus veranderen betekent dat elke eigenaar zijn
* lokale gegevens kwijt is. Dat maakt op de relay niets uit (die kent alleen het
* `OwnerId`), maar de geschiedenis moet dan opnieuw opgehaald worden.
*/
export const APP_NAME = AppName.orThrow('WhatsnextRelayClient');
/**
* Het schema: één tabel met een label en een blob.
*
* Bewust klein. Wat hier beproefd wordt is de relay, niet een gegevensmodel. Het
* label is er om in een uitdraai te kunnen zien wélke rij je terugkrijgt, en de
* blob is waar de omvang in zit die de bytegrens van de relay moet raken.
*/
export const Schema = {
blob: {
id: id('Blob'),
label: NonEmptyTrimmedString100,
body: Uint8ArrayType,
},
};
const allesQuery = createQueryBuilder(Schema)((db) =>
db.selectFrom('blob').selectAll().orderBy('createdAt'),
);
// ═══════════════════════════════════════════════════════════════════════════════
// De gedeelde afhankelijkheden: één stel per proces, voor alle eigenaars samen.
//
// Dat is geen zuinigheid maar een eis. `createEvoluDeps` vraagt via
// `navigator.locks` het slot "tab" aan en kondigt de houder ervan aan als "tab
// leader"; de database-worker van een instantie breekt af met "Expected tab
// leader port." als die aankondiging er niet is. Eén slot betekent één houder,
// dus een tweede stel afhankelijkheden in hetzelfde proces wordt nooit leider en
// de tweede eigenaar valt om.
//
// Precies dat gebeurde op 09-09-2026 bij de gebruiker, bij de tweede eigenaar.
// Het stond al als valstrik 2 in PLAN.md §4e en in het commentaar van
// evolu-node.js ("Eén keer aanroepen en hergebruiken voor alle eigenaars"), en
// het was er alsnog in geslopen omdat `openStore` per aanroep een eigen stel
// maakte. In een browser is dit vanzelf goed: daar is er één tabblad met één
// gedeelde worker voor alle instanties.
//
// Meegeteld wordt hoeveel stores er open zijn, zodat het pas wordt opgeruimd als
// de laatste sluit.
// ═══════════════════════════════════════════════════════════════════════════════
let gedeeld = null;
/** Wie er een defect wil horen. Per open store één, en die gaat er bij het sluiten uit. */
const defectMelders = new Set();
/** Wie er wil weten welke WebSocket-URL er werkelijk geopend wordt. */
const socketMelders = new Set();
const haalGedeeldeDeps = (directory, consoleLevel) => {
if (gedeeld === null) {
const { deps, dispose } = createNodeEvoluDeps({
directory,
consoleLevel,
onDefect: (defect) => {
// Naar iedereen die luistert. Een defect komt uit een gedeelde worker en
// is dus niet aan één eigenaar toe te schrijven; doen alsof van wel zou
// het onderzoek de verkeerde kant op sturen.
for (const melder of defectMelders) melder(defect);
},
onWebSocket: (url) => {
for (const melder of socketMelders) melder(url);
},
});
gedeeld = { directory, deps, dispose, aantal: 0 };
} else if (gedeeld.directory !== directory) {
throw new Error(
`Er is al een gegevensmap open (${gedeeld.directory}). Eén map per proces; `
+ 'sluit eerst alle stores voordat je een andere map gebruikt.',
);
}
gedeeld.aantal += 1;
return gedeeld.deps;
};
const laatGedeeldeDepsLos = async () => {
if (gedeeld === null) return;
gedeeld.aantal -= 1;
if (gedeeld.aantal > 0) return;
const weg = gedeeld;
gedeeld = null;
await weg.dispose();
};
/** Maakt een nieuwe eigenaar met verse sleutels. */
export const nieuweEigenaar = () =>
createAppOwner(createOwnerSecret({ randomBytes: createRandomBytes() }));
/**
* Herstelt een eigenaar uit zijn mnemonic.
*
* Dit is de enige weg terug: de mnemonic is wat bewaard wordt, het `OwnerId` is
* er een afgeleide van. Een onleesbare mnemonic gooit, en dat hoort: stil een
* andere eigenaar maken zou een proef opleveren die nergens over gaat.
*/
export const eigenaarUitMnemonic = (mnemonic) => createAppOwner(mnemonicToOwnerSecret(mnemonic));
/**
* Opent de opslag van één eigenaar.
*
* `relayUrl` weglaten of leeg laten geeft een instantie zonder synchronisatie.
* Dat is niet alleen voor toetsen nuttig: het is ook hoe je een eigenaar met
* gegevens vult vóórdat je hem voor het eerst laat verbinden, en dat is wat de
* proef "toelaten en opnieuw verbinden" nodig heeft.
*
* @param {{directory: string, owner: object, relayUrl?: string, onDefect?: Function, consoleLevel?: string}} config
*/
export const openStore = async ({
directory,
owner,
relayUrl,
onDefect,
onWebSocket,
consoleLevel = 'error',
}) => {
const deps = haalGedeeldeDeps(directory, consoleLevel);
if (onDefect) defectMelders.add(onDefect);
if (onWebSocket) socketMelders.add(onWebSocket);
// Wél een eigen run per store, want die bezit de instantie: één eigenaar
// sluiten hoort de andere niet te raken. Alleen de afhankelijkheden eronder
// zijn gedeeld.
const run = createRun(deps);
// Het OwnerId gaat mee in de URL, en dat is wat de relay bij de
// WebSocket-upgrade leest om te beslissen of deze eigenaar erin mag. Zonder dat
// is er niets te beslissen en is de hele allowlist niet te beproeven; zie §10
// van HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md.
const transports = relayUrl
? [createOwnerWebSocketTransport({ url: relayUrl, ownerId: owner.id })]
: [];
// De instantie start ZONDER sync en de transport gaat er daarna met `useOwner`
// in, ook al is het de eigen appOwner. Dat is geen omweg maar het enige wat
// losmaken werkelijk losmaakt.
//
// Waarom: de socket zit niet in deze instantie maar in de gedeelde worker, en
// die blijft staan zolang er nog één store open is (zie `gedeeld` bovenaan).
// Een transport die je in de config meegeeft, kun je daarna nergens meer
// opzeggen; het gevolg was dat de relay een eigenaar bleef zien nadat je hem
// in de cliënt had losgekoppeld of zelfs vergeten, tot je het proces stopte.
// Gemeld door de gebruiker op 10-09-2026. `useOwner` geeft een opzegging terug
// en Evolu telt de verwijzingen naar een transport, dus de laatste opzegging
// sluit de socket.
const uitkomst = await run(
createEvolu(Schema, { appName: APP_NAME, appOwner: owner, transports: [] }),
);
if (!uitkomst.ok) {
await run[Symbol.asyncDispose]();
if (onDefect) defectMelders.delete(onDefect);
if (onWebSocket) socketMelders.delete(onWebSocket);
await laatGedeeldeDepsLos();
throw new Error(`Evolu wilde niet starten: ${JSON.stringify(uitkomst.error)}`);
}
const evolu = uitkomst.value;
// `useOwner` eist een niet-lege lijst, dus alleen als er een relay is.
let zegSyncOp = transports.length > 0 ? evolu.useOwner(owner, transports) : null;
return {
ownerId: owner.id,
/** De URL waarmee verbonden wordt, of null bij een instantie zonder sync. */
transportUrl: transports[0]?.url ?? null,
/**
* Schrijft een blob weg. Geeft het id van de nieuwe rij terug.
*
* Asynchroon, en dat is geen stijlkeuze. `evolu.insert` geeft meteen een id
* terug en zet de schrijfactie in de wachtrij van de database-worker; sluit
* je vlak daarna af, dan is de rij nooit op schijf beland. Dat kostte op
* 09-09-2026 een `lees` die "nog geen blobs" meldde over een blob die
* zojuist bevestigd was. De query erachter dwingt de heenreis af.
*/
schrijf: async (label, body) => {
const geschreven = evolu.insert('blob', { label, body });
await evolu.loadQuery(allesQuery);
return geschreven;
},
/** Alle blobs van deze eigenaar, oudste eerst. */
lees: () => evolu.loadQuery(allesQuery),
/** Voor wie meer wil dan schrijven en lezen. */
evolu,
/**
* Sluit af. Eerst de run, dan de afhankelijkheden: de run bezit de instantie
* en die gebruikt de workers die in de afhankelijkheden zitten. Andersom
* eindigt elk afsluiten met een SuppressedError zonder oorzaak.
*
* De gedeelde afhankelijkheden gaan pas weg als de láátste store sluit; zie
* de uitleg bovenaan bij `gedeeld`.
*/
sluit: async () => {
// Eerst de sync opzeggen en dan de run weggooien. Andersom is de instantie
// al weg als de opzegging aankomt.
if (zegSyncOp) {
zegSyncOp();
zegSyncOp = null;
}
await run[Symbol.asyncDispose]();
if (onDefect) defectMelders.delete(onDefect);
if (onWebSocket) socketMelders.delete(onWebSocket);
await laatGedeeldeDepsLos();
},
};
};
+99
View File
@@ -0,0 +1,99 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Waar staat de relay, en waar mogen de gegevens staan.
//
// Het adres van de Umbrel staat NIET in deze repo en hoort daar ook niet in:
// dit is een publieke app store, en de .gitignore van de repo-root heeft er een
// eigen kopje over. Het komt dus uit de omgeving, met `.env` als de gewone weg
// en `.env.sample` als het voorbeeld dat wél in de repo staat.
//
// Geen dotenv-pakket: dat is een afhankelijkheid voor twintig regels, en die
// twintig regels zijn hier beter te lezen dan de instellingen van een pakket.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** De wortel van dit gereedschap, ongeacht vanwaar het gestart wordt. */
export const WORTEL = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Leest een `.env`-bestand.
*
* Regels van de vorm `NAAM=waarde`. Lege regels en regels die met `#` beginnen
* worden overgeslagen, en aanhalingstekens om de waarde gaan eraf. Meer niet: wie
* hier een regel met een dubbele bodem in zet, krijgt hem letterlijk terug.
*/
export const leesEnvBestand = (inhoud) => {
const uit = {};
for (const regel of inhoud.split(/\r?\n/)) {
const kaal = regel.trim();
if (kaal === '' || kaal.startsWith('#')) continue;
const isGelijk = kaal.indexOf('=');
if (isGelijk <= 0) continue;
const naam = kaal.slice(0, isGelijk).trim();
let waarde = kaal.slice(isGelijk + 1).trim();
if (
(waarde.startsWith('"') && waarde.endsWith('"') && waarde.length >= 2)
|| (waarde.startsWith("'") && waarde.endsWith("'") && waarde.length >= 2)
) {
waarde = waarde.slice(1, -1);
}
uit[naam] = waarde;
}
return uit;
};
/**
* Maakt van een adres een WebSocket-URL, of gooit met uitleg.
*
* De relay is een WebSocket-server, dus `ws://` of `wss://`. Een `http://` dat
* iemand uit de adresbalk plakt wordt omgezet in plaats van geweigerd: dat is
* de meest voorkomende vergissing en er is niets dubbelzinnigs aan.
*
* Wat er wél uitkomt is een adres zonder schema (`umbrel.local:3852`), en dat
* krijgt `ws://`. Zonder poort blijft het zoals het is: achter een reverse proxy
* met TLS is `wss://host` zonder poort juist de goede vorm.
*/
export const naarRelayUrl = (adres) => {
if (typeof adres !== 'string' || adres.trim() === '') {
throw new Error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
}
const kaal = adres.trim();
if (kaal.startsWith('ws://') || kaal.startsWith('wss://')) return kaal;
if (kaal.startsWith('http://')) return `ws://${kaal.slice('http://'.length)}`;
if (kaal.startsWith('https://')) return `wss://${kaal.slice('https://'.length)}`;
if (kaal.includes('://')) {
throw new Error(`"${kaal}" heeft een schema dat hier niets betekent. Gebruik ws:// of wss://.`);
}
return `ws://${kaal}`;
};
/**
* De instellingen: eerst de omgeving, dan `.env`, dan de standaardwaarden.
*
* De omgeving wint van het bestand, zodat je één keer iets anders kunt proberen
* zonder het bestand aan te raken.
*/
export const leesConfig = ({ env = process.env, wortel = WORTEL } = {}) => {
let uitBestand = {};
try {
uitBestand = leesEnvBestand(readFileSync(join(wortel, '.env'), 'utf8'));
} catch (oorzaak) {
if (oorzaak.code !== 'ENOENT') throw oorzaak;
}
const haal = (naam) => env[naam] ?? uitBestand[naam];
const adres = haal('RELAY_URL');
return {
/** Het adres van de relay, of `null` als het er niet is. */
relayUrl: adres ? naarRelayUrl(adres) : null,
/** Waar het register en de databases staan. */
dataDir: resolve(wortel, haal('DATA_DIR') ?? 'data'),
/** De poort van het bedieningsvlak. */
uiPoort: Number.parseInt(haal('UI_PORT') ?? '4380', 10),
};
};
+224
View File
@@ -0,0 +1,224 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Een Evolu-cliënt op Node in elkaar zetten.
//
// Dit is het stuk waarvan het plan zei dat het eerst geverifieerd moest worden,
// en het antwoord was niet wat we hoopten: **`@evolu/nodejs` bevat geen cliënt.**
// Het pakket levert de relay plus losse bouwstenen (de better-sqlite3-driver, een
// BroadcastChannel, een klok), maar er is geen `createEvoluDeps` voor Node zoals
// `@evolu/web` die voor de browser heeft. Wie op Node een cliënt wil, zet de
// afhankelijkheden zelf in elkaar, en dat is wat hier gebeurt.
//
// Nagetrokken op 09-09-2026 in @evolu/common 8.7.0 en @evolu/nodejs 3.1.0. Wat
// `createEvolu` verlangt is `EvoluPlatformDeps`, en dat is de som van zes dingen:
//
// createDbWorker de database-worker
// sharedWorker de gedeelde worker, die de synchronisatie doet
// createBroadcastChannel, createMessageChannel, lockManager, reloadApp
//
// Alles draait hier in één proces. De "worker" en de "shared worker" zijn dus de
// in-memory varianten uit `@evolu/common`, en dat is precies waar die voor
// bestaan: hun eigen documentatie noemt ze de terugvaloptie voor platforms zonder
// echte workers. Voor een testcliënt is dat geen concessie, want er is niets te
// winnen met een tweede draad.
//
// `lockManager` komt van `navigator.locks`. Dat is er sinds Node 24, en het is de
// reden dat `package.json` die ondergrens stelt; de documentatie van Evolu noemt
// "Web and Node.js 24+" bij dit onderdeel met zoveel woorden.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import {
createConsole,
createConsoleStoreOutput,
createMessageChannel,
createMessagePort,
createRun,
createSharedWorker,
createWebSocket,
createWorker,
} from '@evolu/common';
import { createEvoluDeps, initSharedWorker, startDbWorker } from '@evolu/common/local-first';
import { createBetterSqliteDriver, createBroadcastChannel } from '@evolu/nodejs';
/**
* De SQLite-driver, maar met alle bestanden in één map.
*
* `createBetterSqliteDriver` maakt `${name}.db` in de werkmap, waarbij `name` de
* instantienaam is die Evolu uit `appName` en de eigenaar afleidt. Elke eigenaar
* krijgt dus al zijn eigen bestand; wat ontbreekt is een map om ze in te zetten.
* Dit voorvoegsel doet dat zonder `process.chdir`, want dat is procesbreed en
* deze cliënt draait meerdere eigenaars naast elkaar.
*/
const createSqliteDriverIn = (directory) => (name, options) =>
createBetterSqliteDriver(join(directory, name), options);
/**
* Zet de afhankelijkheden neer die `createEvolu` op Node nodig heeft.
*
* Geeft naast de afhankelijkheden een `dispose` terug. Die hoort aangeroepen te
* worden bij het afsluiten: de runs die hieronder gemaakt worden bezitten de
* workers, en een worker die blijft staan houdt het proces open.
*
* @param {{directory: string, consoleLevel?: string}} config
*/
export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect, onWebSocket }) => {
// De map moet er zijn vóórdat better-sqlite3 hem opent; die maakt hem niet aan
// en gooit "Cannot open database because the directory does not exist". Dat
// gebeurt binnen een worker, dus zonder deze regel is het symptoom een
// `loadQuery` die nooit antwoordt en niet een fout die je kunt lezen.
mkdirSync(directory, { recursive: true });
const console = createConsole({ level: consoleLevel });
const consoleStoreOutput = createConsoleStoreOutput();
// De basis die zowel de database-worker als de gedeelde worker verlangt.
// `consoleStoreOutputEntry` is hoe een worker zijn logregels naar buiten geeft;
// wij lezen dat niet uit, maar de afhankelijkheid moet er zijn.
const workerDeps = {
console,
consoleStoreOutputEntry: consoleStoreOutput.entry,
createMessagePort,
};
const lockManager = globalThis.navigator?.locks;
if (!lockManager) {
throw new Error(
'navigator.locks ontbreekt. Dit vraagt Node 24 of hoger; zie de kop van dit bestand.',
);
}
// Een defect in een worker is standaard een uitzondering uit een microtaak, en
// die komt dus nergens terecht waar je hem kunt lezen: het proces valt om met
// "An error was suppressed during disposal" en zonder oorzaak. Deze cliënt
// bestaat om fouten zichtbaar te maken, dus dat wordt hier omgeleid.
const reportDefect = (defect) => {
if (onDefect) {
onDefect(defect);
return;
}
console.error('[evolu] defect', defect);
};
// Twee runs, want de twee workers hebben verschillende afhankelijkheden. Ze
// worden hier vastgehouden zodat `dispose` ze allebei kan opruimen.
const dbRun = createRun({
...workerDeps,
createBroadcastChannel,
lockManager,
createSqliteDriver: createSqliteDriverIn(directory),
reportDefect,
});
// De WebSocket met een luikje erin. Wat Evolu werkelijk opent is namelijk niet
// af te leiden uit wat je hem meegeeft: de `OwnerId` in de URL bepaalt wie de
// relay ziet, en dat is precies de vraag waar dit gereedschap over gaat. Zonder
// dit luikje kun je alleen gissen waarom er een geweigerde eigenaar op de
// statuspagina verschijnt.
//
// Niet alleen welke URL, maar ook wat ermee gebeurt. Een relay die de socket
// meteen weer dichtgooit ziet er van de cliëntkant hetzelfde uit als een relay
// die traag is: Evolu probeert het gewoon opnieuw. De sluitcode is wat die twee
// uit elkaar houdt.
const gevolgdeWebSocket = onWebSocket
? (url, options = {}) => {
onWebSocket({ wat: 'openen', url });
return createWebSocket(url, {
...options,
onOpen: () => {
onWebSocket({ wat: 'open', url });
options.onOpen?.();
},
onError: (fout) => {
onWebSocket({ wat: 'fout', url, detail: fout?.type ?? String(fout) });
options.onError?.(fout);
},
onClose: (gebeurtenis) => {
onWebSocket({
wat: 'dicht',
url,
detail: `code ${gebeurtenis?.code ?? '?'}${gebeurtenis?.reason ? ` ${gebeurtenis.reason}` : ''}`,
});
options.onClose?.(gebeurtenis);
},
});
}
: createWebSocket;
const sharedRun = createRun({
...workerDeps,
createBroadcastChannel,
createMessageChannel,
createWebSocket: gevolgdeWebSocket,
lockManager,
reportDefect,
});
const createDbWorker = () =>
createWorker((self) => {
void dbRun(startDbWorker(self));
});
// `initSharedWorker` geeft een AsyncDisposableStack terug, en die moet iemand
// bezitten. Laat je hem vallen, dan wordt hij pas opgeruimd als de run eronder
// al weg is, en dat is precies de "Cannot use a disposed object" die deze
// cliënt bij elk afsluiten liet zien.
let sharedWorkerResources = null;
const sharedWorker = createSharedWorker((self) => {
void Promise.resolve(sharedRun(initSharedWorker(self))).then((result) => {
if (result.ok) sharedWorkerResources = result.value;
});
});
// En dan door `createEvoluDeps` heen, want dat is geen optionele verpakking.
// Dáár wordt de gedeelde worker aan de database-worker geknoopt en wordt de
// "tab leader" aangekondigd; zonder die stap breekt de gedeelde worker af met
// "Expected tab leader port." zodra de eerste schrijfactie binnenkomt. Dat is
// de tweede valstrik van vandaag: de typen laten `EvoluPlatformDeps` toe waar
// `EvoluDeps` hoort, dus het compileert en het faalt pas als je iets doet.
//
// Eén keer aanroepen en hergebruiken voor alle eigenaars, zoals de
// documentatie voorschrijft. Dat is ook precies goed voor deze cliënt: in een
// browser bedient één gedeelde worker alle instanties van alle tabbladen, en
// hier bedient hij alle eigenaars in dit proces.
const evoluDeps = createEvoluDeps({
console,
createDbWorker,
createBroadcastChannel,
createMessageChannel,
lockManager,
// Een cliënt zonder venster heeft niets te herladen. Evolu roept dit aan als
// hij de app wil laten herstarten; hier is dat een logregel waard en verder
// niets, want het proces herstarten zou de proef die loopt weggooien.
reloadApp: () => {
console.warn('[evolu] reloadApp aangeroepen, genegeerd in deze cliënt');
},
sharedWorker,
});
return {
deps: evoluDeps,
/**
* Ruimt de workers op. Asynchroon, en dat moet.
*
* Het afsluiten van een worker is zelf een taak op de run die hem draagt.
* Wordt die run meteen weggegooid, dan loopt dat afsluiten tegen "Cannot use
* a disposed object" aan, en dat komt naar buiten als een SuppressedError
* zonder oorzaak. De volgorde is dus: eerst de afhankelijkheden, dan de runs
* afwachten in plaats van weggooien.
*/
dispose: async () => {
evoluDeps[Symbol.dispose]();
// De in-memory workers bezorgen hun berichten asynchroon, dus op dit punt
// staat er nog post in de wacht die bij aankomst zijn eigen run gebruikt.
// Even doorlaten voordat die runs weggaan.
await new Promise((klaar) => {
setImmediate(klaar);
});
if (sharedWorkerResources) await sharedWorkerResources[Symbol.asyncDispose]();
await sharedRun[Symbol.asyncDispose]();
await dbRun[Symbol.asyncDispose]();
},
};
};
+324
View File
@@ -0,0 +1,324 @@
<!doctype html>
<!--
Het bedieningsvlak van de testclient.
Engels op het scherm zou hier misstaan: dit is gereedschap voor de eigen
werkbank en niet iets wat een bezoeker van de app store ziet. De taalgrens in
Werkafspraken.md loopt langs "ziet de gebruiker van de app dit", en dat is hier nee.
Geen framework en geen bouwstap, net als de statuspagina's van de twee apps.
-->
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Relay-testclient</title>
<style>
:root {
color-scheme: light dark;
--grond: #f6f6f4;
--kaart: #ffffff;
--rand: #e2e2dd;
--tekst: #1b1b19;
--zacht: #6b6b64;
--accent: #b45309;
--goed: #15803d;
--fout: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--grond: #17171a;
--kaart: #1f1f23;
--rand: #33333a;
--tekst: #ececea;
--zacht: #9a9a94;
--accent: #f59e0b;
--goed: #4ade80;
--fout: #f87171;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px 20px 64px;
background: var(--grond);
color: var(--tekst);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 940px; margin: 0 auto; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--zacht); font-weight: 600; }
.kop { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline;
margin-bottom: 20px; color: var(--zacht); font-size: 13px; }
.kaart { background: var(--kaart); border: 1px solid var(--rand);
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; }
.rij { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
.naam { font-weight: 600; font-size: 16px; }
.id { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
font-size: 12px; color: var(--zacht); word-break: break-all; }
.stip { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--zacht); margin-right: 6px; vertical-align: 1px; }
.stip.aan { background: var(--goed); }
.duw { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
button, input {
font: inherit; border-radius: 7px; border: 1px solid var(--rand);
background: var(--kaart); color: var(--tekst); padding: 5px 11px;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:disabled { opacity: 0.45; cursor: default; }
input { width: 8em; }
input.breed { width: 12em; }
.leeg, .melding { color: var(--zacht); font-size: 14px; }
.melding.fout { color: var(--fout); }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
td { padding: 3px 8px 3px 0; border-top: 1px solid var(--rand); }
td.getal { text-align: right; font-variant-numeric: tabular-nums; }
#log { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
font-size: 12px; max-height: 260px; overflow-y: auto; }
#log div { padding: 2px 0; border-top: 1px solid var(--rand); }
#log div:first-child { border-top: 0; }
#log time { color: var(--zacht); margin-right: 10px; }
</style>
</head>
<body>
<main>
<h1>Relay-testclient</h1>
<div class="kop">
<span>Relay: <b id="relay">…</b></span>
<span id="waarschuwing" class="melding fout" hidden>
Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.
</span>
</div>
<h2>Nieuwe eigenaar</h2>
<div class="kaart">
<div class="rij">
<input id="nieuwNaam" class="breed" placeholder="naam" maxlength="40" />
<button id="nieuwKnop">Aanmaken</button>
<span id="nieuwMelding" class="melding"></span>
</div>
</div>
<h2>Eigenaars</h2>
<div id="eigenaars"></div>
<h2>Log</h2>
<div class="kaart" id="log"></div>
</main>
<script>
const $ = (id) => document.getElementById(id);
let bezig = false;
// Wat er als laatste getekend is. De lijst wordt in zijn geheel opnieuw
// opgebouwd, dus zonder deze vergelijking zou elke drie seconden het veld
// met de omvang leeggaan terwijl je erin typt, en zou een knop onder je
// muis vervangen worden.
let laatsteStaat = '';
// De ingetypte omvang per eigenaar, zodat die een hertekening overleeft.
const omvangPer = new Map();
const roep = async (actie, waarden) => {
const antwoord = await fetch(`/api/${actie}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(waarden ?? {}),
});
const uit = await antwoord.json();
if (!antwoord.ok) throw new Error(uit.fout ?? 'Er ging iets mis.');
return uit;
};
const tijd = (iso) => (iso ? new Date(iso).toLocaleTimeString('nl-NL') : '');
const knop = (tekst, aan) => {
const el = document.createElement('button');
el.textContent = tekst;
el.addEventListener('click', async () => {
if (bezig) return;
bezig = true;
el.disabled = true;
try {
await aan();
} catch (oorzaak) {
alert(oorzaak.message);
} finally {
bezig = false;
el.disabled = false;
await ververs();
}
});
return el;
};
const kaartVoor = (eigenaar) => {
const kaart = document.createElement('div');
kaart.className = 'kaart';
const rij = document.createElement('div');
rij.className = 'rij';
const naam = document.createElement('span');
naam.className = 'naam';
naam.innerHTML = `<span class="stip${eigenaar.verbonden ? ' aan' : ''}"></span>`;
naam.append(eigenaar.naam);
rij.append(naam);
const toestand = document.createElement('span');
toestand.className = 'melding';
toestand.textContent = eigenaar.verbonden
? `${eigenaar.metRelay ? 'verbonden' : 'lokaal open'} sinds ${tijd(eigenaar.sinds)}${
eigenaar.blobs === null ? '' : ` · ${eigenaar.blobs} blob(s)`
}`
: 'niet verbonden';
rij.append(toestand);
if (eigenaar.test) {
const test = document.createElement('span');
test.className = eigenaar.test.geaccepteerd ? 'melding' : 'melding fout';
test.textContent = `getest ${tijd(eigenaar.test.tijd)}: ${eigenaar.test.status ?? '-'} ${eigenaar.test.uitleg}`;
rij.append(test);
}
const duw = document.createElement('div');
duw.className = 'duw';
const omvang = document.createElement('input');
omvang.value = omvangPer.get(eigenaar.naam) ?? '64';
omvang.title = 'Omvang van de blob in bytes';
omvang.style.width = '6em';
omvang.addEventListener('input', () => omvangPer.set(eigenaar.naam, omvang.value));
// Verbinden gaat uit zodra een test zegt dat deze eigenaar geweigerd
// wordt. Niet uit netheid: Evolu blijft bij een weigering eindeloos
// opnieuw proberen, en élke poging is een regel in de weigerlijst van de
// relay. Onbekend telt als toegestaan, want anders moet je eerst testen
// om iets te mogen, en dat is zelf ook een poging.
const verbindKnop = eigenaar.verbonden
? knop('Verbreken', () => roep('verbreek', { naam: eigenaar.naam }))
: knop('Verbinden', () => roep('verbind', { naam: eigenaar.naam }));
if (!eigenaar.verbonden && !eigenaar.magVerbinden) {
verbindKnop.disabled = true;
verbindKnop.title = 'De relay weigert deze eigenaar. Laat hem toe op de statuspagina van de app en test opnieuw.';
}
duw.append(
// Geen melding in een dialoogvenster: de uitkomst komt naast de naam te
// staan en in het log, en die blijven staan. Een dialoog moet je
// wegklikken en is daarna weg.
knop('Testen', () => roep('test', { naam: eigenaar.naam })),
verbindKnop,
omvang,
knop('Blob schrijven', () =>
roep('schrijf', { naam: eigenaar.naam, label: `blob ${tijd(new Date().toISOString())}`, bytes: omvang.value }),
),
knop('Lezen', async () => {
const uit = await roep('lees', { naam: eigenaar.naam });
toonRijen(kaart, uit.rijen);
}),
knop('Vergeten', async () => {
if (!confirm(`"${eigenaar.naam}" uit het register halen? De lokale database blijft staan.`)) return;
await roep('vergeet', { naam: eigenaar.naam });
}),
);
rij.append(duw);
kaart.append(rij);
const id = document.createElement('div');
id.className = 'id';
id.textContent = eigenaar.ownerId;
kaart.append(id);
return kaart;
};
const toonRijen = (kaart, rijen) => {
kaart.querySelector('table')?.remove();
const tabel = document.createElement('table');
if (rijen.length === 0) {
const cel = tabel.insertRow().insertCell();
cel.className = 'leeg';
cel.textContent = 'Nog geen blobs.';
}
for (const rij of rijen) {
const tr = tabel.insertRow();
tr.insertCell().textContent = rij.label;
const bytes = tr.insertCell();
bytes.className = 'getal';
bytes.textContent = `${rij.bytes} bytes`;
const id = tr.insertCell();
id.className = 'id';
id.textContent = rij.id;
}
kaart.append(tabel);
};
const ververs = async (altijd = false) => {
const ruw = await (await fetch('/api/status')).text();
if (!altijd && ruw === laatsteStaat) return;
laatsteStaat = ruw;
const staat = JSON.parse(ruw);
$('relay').textContent = staat.relayUrl ?? 'niet ingesteld';
$('waarschuwing').hidden = Boolean(staat.relayUrl);
const lijst = $('eigenaars');
lijst.textContent = '';
if (staat.eigenaars.length === 0) {
const leeg = document.createElement('div');
leeg.className = 'kaart leeg';
leeg.textContent = 'Nog geen eigenaars. Maak er hierboven een aan.';
lijst.append(leeg);
}
for (const eigenaar of staat.eigenaars) lijst.append(kaartVoor(eigenaar));
const log = $('log');
log.textContent = '';
if (staat.log.length === 0) {
log.className = 'kaart leeg';
log.textContent = 'Nog niets gebeurd.';
} else {
log.className = 'kaart';
for (const regel of staat.log) {
const el = document.createElement('div');
const t = document.createElement('time');
t.textContent = tijd(regel.tijd);
el.append(t, regel.tekst);
log.append(el);
}
}
};
$('nieuwKnop').addEventListener('click', async () => {
const naam = $('nieuwNaam').value.trim();
const melding = $('nieuwMelding');
melding.className = 'melding';
melding.textContent = '';
try {
const uit = await roep('nieuw', { naam });
$('nieuwNaam').value = '';
melding.textContent = `"${uit.naam}" aangemaakt.`;
await ververs();
} catch (oorzaak) {
melding.className = 'melding fout';
melding.textContent = oorzaak.message;
}
});
$('nieuwNaam').addEventListener('keydown', (gebeurtenis) => {
if (gebeurtenis.key === 'Enter') $('nieuwKnop').click();
});
void ververs();
// Verversen terwijl je met een knop bezig bent zou de kaart onder je muis
// vervangen, dus dat wordt overgeslagen.
setInterval(() => {
if (!bezig) void ververs();
}, 3000);
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Aankloppen bij de relay en de HTTP-statuscode teruggeven.
//
// Dit bestaat omdat Evolu een weigering niet van een relay die plat ligt
// onderscheidt: de cliënt is local-first en probeert het gewoon opnieuw. Voor
// een gereedschap dat juist de allowlist moet beproeven is dat het verschil
// tussen een proef en een gok; zie het plan Testclient, OPEN.md punt 1.
//
// Het kan zo goedkoop omdat de relay zijn beslissing bij de WebSocket-upgrade
// neemt en niet bij een bericht: het `OwnerId` staat in de URL, en een weigering
// is een HTTP 401 op de upgrade. Zie §10 van
// HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md.
//
// Met node:http en niet met de WebSocket van Node, en dat is de hele reden dat
// dit bestand er is: de WebSocket-API geeft je bij een weigering een `error` en
// geen statuscode, en 401 tegenover 404 tegenover "niets luistert" is precies
// wat je wilt weten.
// ═══════════════════════════════════════════════════════════════════════════════
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { randomBytes } from 'node:crypto';
/**
* De URL waarmee deze eigenaar zou verbinden.
*
* Dezelfde vorm die `createOwnerWebSocketTransport` maakt. Hij staat hier apart
* zodat er getoetst kan worden dat de twee niet uit elkaar lopen; loopt dat wel
* uit elkaar, dan klopt de proef niet en zie je dat nergens aan.
*/
export const upgradeUrl = (relayUrl, ownerId) => `${relayUrl}?ownerId=${ownerId}`;
/**
* Klopt aan en geeft terug wat er gebeurde.
*
* @returns {Promise<{status: number|null, geaccepteerd: boolean, uitleg: string}>}
*/
export const klopAan = (relayUrl, ownerId, { timeoutMs = 5000 } = {}) =>
new Promise((klaar) => {
let url;
try {
url = new URL(upgradeUrl(relayUrl, ownerId));
} catch {
klaar({ status: null, geaccepteerd: false, uitleg: `"${relayUrl}" is geen bruikbaar adres` });
return;
}
const beveiligd = url.protocol === 'wss:';
const verzoek = (beveiligd ? httpsRequest : httpRequest)({
hostname: url.hostname,
port: url.port || (beveiligd ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': randomBytes(16).toString('base64'),
},
});
let afgerond = false;
const antwoord = (uitkomst) => {
if (afgerond) return;
afgerond = true;
verzoek.destroy();
klaar(uitkomst);
};
// Een geslaagde upgrade komt níet als 'response' binnen maar als 'upgrade'.
// Dat is meteen het antwoord: de relay heeft deze eigenaar toegelaten.
verzoek.on('upgrade', () => {
antwoord({ status: 101, geaccepteerd: true, uitleg: 'toegelaten' });
});
verzoek.on('response', (res) => {
const status = res.statusCode ?? null;
antwoord({
status,
geaccepteerd: false,
uitleg:
status === 401
? 'geweigerd: deze eigenaar mag er niet in'
: `geweigerd met status ${status}`,
});
});
verzoek.on('error', (oorzaak) => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen verbinding: ${oorzaak.message}` });
});
verzoek.setTimeout(timeoutMs, () => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen antwoord binnen ${timeoutMs} ms` });
});
verzoek.end();
});
+157
View File
@@ -0,0 +1,157 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het register: welke eigenaars bestaan er, en hoe kom je erbij terug.
//
// Eén bestand, `owners.json`, met per eigenaar een naam, zijn mnemonic en zijn
// `OwnerId`. De mnemonic is het enige dat werkelijk bewaard hoeft te worden; het
// `OwnerId` staat erbij omdat je het nodig hebt om een eigenaar op de
// statuspagina van de app terug te vinden, en omdat je het dan kunt opzoeken
// zonder Evolu te starten.
//
// **Dit is sleutelmateriaal.** Het heeft buiten deze proefopstelling geen
// waarde, maar het bestand staat in .gitignore en het hoort daar te blijven; zie
// het plan Testclient, PLAN.md §4a.
//
// Alles hier is een gewone functie op een pad. Geen Evolu, geen netwerk: dat is
// wat dit het enige deel van de cliënt maakt dat zonder relay en zonder
// fixtures te toetsen is, en het is dezelfde scheiding die policy.js aan de
// relay-kant heeft.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Een naam is een herkenpunt voor jezelf en gaat nooit de deur uit.
*
* Toch begrensd, want hij belandt in een JSON-bestand en straks op een pagina.
* Letters, cijfers, streepje en liggend streepje; dat is genoeg om "trezor-mac"
* of "proef_2" te kunnen schrijven en het sluit een naam met een schuine streep
* erin uit, die er als pad uit zou zien.
*/
const NAAM_PATROON = /^[A-Za-z0-9_-]{1,40}$/;
export const isBruikbareNaam = (naam) => typeof naam === 'string' && NAAM_PATROON.test(naam);
const LEEG = { version: 1, owners: [] };
/**
* Leest het bestand. Geeft `null` bij iets wat niet klopt.
*
* Bewust streng, en om dezelfde reden als in policy.js: een half begrepen
* bestand is gevaarlijker dan geen bestand. Hier zou dat betekenen dat een
* eigenaar stil verdwijnt terwijl zijn mnemonic wél op schijf staat, en dan is
* de proef die eroverheen liep niet meer te herhalen.
*/
export const normaliseer = (ruw) => {
if (ruw === null || typeof ruw !== 'object' || Array.isArray(ruw)) return null;
if (ruw.version !== 1) return null;
if (!Array.isArray(ruw.owners)) return null;
const owners = [];
for (const eigenaar of ruw.owners) {
if (eigenaar === null || typeof eigenaar !== 'object') return null;
if (!isBruikbareNaam(eigenaar.naam)) return null;
if (typeof eigenaar.mnemonic !== 'string' || eigenaar.mnemonic.length === 0) return null;
if (typeof eigenaar.ownerId !== 'string' || eigenaar.ownerId.length === 0) return null;
if (owners.some((eerder) => eerder.naam === eigenaar.naam)) return null;
owners.push({
naam: eigenaar.naam,
mnemonic: eigenaar.mnemonic,
ownerId: eigenaar.ownerId,
aangemaakt: typeof eigenaar.aangemaakt === 'string' ? eigenaar.aangemaakt : null,
});
}
return { version: 1, owners };
};
/**
* Opent het register in `directory`, en maakt de map aan als hij er niet is.
*
* Een ontbrekend bestand is de begintoestand en geen fout. Een onleesbaar bestand
* wél: dat wordt gemeld en niet stil vervangen, want vervangen is hier hetzelfde
* als weggooien.
*/
export const openRegister = (directory) => {
mkdirSync(directory, { recursive: true });
const pad = join(directory, 'owners.json');
let staat;
try {
staat = normaliseer(JSON.parse(readFileSync(pad, 'utf8')));
} catch (oorzaak) {
if (oorzaak.code === 'ENOENT') {
staat = { ...LEEG, owners: [] };
} else {
throw new Error(`${pad} is niet te lezen: ${oorzaak.message}`);
}
}
if (staat === null) {
throw new Error(
`${pad} klopt niet. Er is niets vervangen; kijk er zelf naar, want hier staan mnemonics in.`,
);
}
// Schrijven gaat via een tijdelijk bestand en een hernoeming. Een half
// geschreven register kost mnemonics, en een hernoeming binnen dezelfde map is
// op elk besturingssysteem dat wij raken één handeling.
const bewaar = () => {
const tijdelijk = `${pad}.nieuw`;
writeFileSync(tijdelijk, `${JSON.stringify(staat, null, 2)}\n`, 'utf8');
renameSync(tijdelijk, pad);
};
return {
pad,
/** Alle eigenaars, in de volgorde waarin ze zijn aangemaakt. */
lijst: () => staat.owners.map((eigenaar) => ({ ...eigenaar })),
/** Eén eigenaar, of `null`. */
zoek: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
return gevonden ? { ...gevonden } : null;
},
/**
* Zet een eigenaar in het register.
*
* Gooit bij een naam die niet kan of al bestaat. Overschrijven zou de
* mnemonic van de vorige eigenaar weggooien, en dat is precies het soort
* stille schade waar het commentaar bovenaan dit bestand over gaat.
*/
voegToe: ({ naam, mnemonic, ownerId, aangemaakt }) => {
if (!isBruikbareNaam(naam)) {
throw new Error(`"${naam}" kan niet als naam. Letters, cijfers, - en _, hoogstens 40 tekens.`);
}
if (staat.owners.some((eigenaar) => eigenaar.naam === naam)) {
throw new Error(`Er is al een eigenaar "${naam}".`);
}
const nieuw = {
naam,
mnemonic,
ownerId,
aangemaakt: aangemaakt ?? new Date().toISOString(),
};
staat = { ...staat, owners: [...staat.owners, nieuw] };
bewaar();
return { ...nieuw };
},
/**
* Haalt een eigenaar uit het register. Gooit als hij er niet in staat.
*
* Let op wat dit níet doet: de database van die eigenaar blijft staan, en op
* de relay verandert er helemaal niets. Vergeten is hier vergeten, geen
* wissen; zie OPEN.md punt 3 en 4 van het plan.
*/
vergeet: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
if (!gevonden) throw new Error(`Geen eigenaar "${naam}".`);
staat = { ...staat, owners: staat.owners.filter((eigenaar) => eigenaar.naam !== naam) };
bewaar();
return { ...gevonden };
},
};
};
+312
View File
@@ -0,0 +1,312 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het bedieningsvlak: één pagina waarop je alle eigenaars ziet en aanstuurt.
//
// Wat dit scherm wél doet staat in het plan Testclient, PLAN.md §4b. Wat het
// NIET doet, en dat is geen tekortkoming maar de poortindeling van de app: de
// leerstand openzetten, iemand blokkeren en labelen. Die lopen via de agent-API
// van Evolu Relay, en die hangt achter de inlog van umbrelOS. Zet hier dus geen
// knoppen bij die dat proberen; ze kunnen niet werken.
//
// De server luistert op localhost en nergens anders. Hier zitten mnemonics
// achter, en dit is gereedschap op je eigen machine.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
import { klopAan } from './probe.js';
const HIER = dirname(fileURLToPath(import.meta.url));
const MAX_BODY_BYTES = 4096;
const MAX_LOG = 200;
export const startUi = async ({ config, register, databasesIn }) => {
/** De open verbindingen, per eigenaar. Alleen deze module raakt hem aan. */
const open = new Map();
const log = [];
/**
* De laatste uitkomst van "Testen", per eigenaar.
*
* Hierop wordt de knop "Verbinden" uitgezet. Onbekend telt als toegestaan, en
* dat is met opzet: anders moet je eerst testen voordat je iets mag, en een
* test is zelf een poging die in de weigerlijst van de relay belandt.
*/
const laatsteTest = new Map();
/**
* Hoeveel sockets er achter elkaar mislukt zijn, per eigenaar.
*
* Evolu is local-first en blijft eindeloos opnieuw proberen. Dat is bij een
* relay die even weg is precies goed, maar bij een eigenaar die geweigerd
* wordt is elke poging een regel in de weigerlijst van de relay. Vandaar de
* teller: na een paar mislukkingen wordt er één keer getest, en bij een
* weigering gaat de verbinding eruit in plaats van door te blijven kloppen.
*/
const socketFouten = new Map();
const FOUTEN_VOOR_TEST = 3;
const meld = (tekst) => {
log.unshift({ tijd: new Date().toISOString(), tekst });
if (log.length > MAX_LOG) log.length = MAX_LOG;
console.log(`${new Date().toLocaleTimeString()} ${tekst}`);
};
const verbind = async (naam, metRelay) => {
if (open.has(naam)) return open.get(naam);
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (metRelay && !config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const store = await openStore({
directory: databasesIn,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: metRelay ? config.relayUrl : null,
onDefect: (defect) => {
meld(`${naam}: defect uit een worker, zie de terminal`);
console.error(defect);
},
onWebSocket: (bericht) => {
if (bericht.wat === 'open') {
socketFouten.set(naam, 0);
meld(`${naam}: socket open`);
return;
}
if (bericht.wat !== 'dicht') return;
const aantal = (socketFouten.get(naam) ?? 0) + 1;
socketFouten.set(naam, aantal);
if (aantal !== FOUTEN_VOOR_TEST) return;
// Precies één keer testen, bij de derde mislukking. Daarna weten we of
// dit een weigering is of een relay die even weg is, en dat scheelt de
// relay een weigerlijst vol pogingen.
meld(`${naam}: ${aantal} sockets mislukt (${bericht.detail}), wordt getest`);
void (async () => {
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
if (uitkomst.geaccepteerd) {
meld(`${naam}: de relay laat hem toe, dus dit is iets anders dan een weigering`);
return;
}
meld(`${naam}: geweigerd (${uitkomst.uitleg}), verbinding wordt verbroken`);
await verbreek(naam);
})();
},
});
const ingang = { store, sinds: new Date().toISOString(), metRelay };
open.set(naam, ingang);
meld(metRelay ? `${naam} verbonden met ${store.transportUrl}` : `${naam} lokaal geopend`);
return ingang;
};
const verbreek = async (naam) => {
const ingang = open.get(naam);
if (!ingang) return;
open.delete(naam);
socketFouten.delete(naam);
await ingang.store.sluit();
meld(`${naam} losgekoppeld`);
};
const status = async () => {
const eigenaars = [];
for (const regel of register.lijst()) {
const ingang = open.get(regel.naam);
let blobs = null;
if (ingang) {
try {
blobs = (await ingang.store.lees()).length;
} catch {
blobs = null;
}
}
const test = laatsteTest.get(regel.naam) ?? null;
eigenaars.push({
naam: regel.naam,
ownerId: regel.ownerId,
aangemaakt: regel.aangemaakt,
verbonden: Boolean(ingang),
metRelay: ingang?.metRelay ?? false,
sinds: ingang?.sinds ?? null,
transportUrl: ingang?.store.transportUrl ?? null,
blobs,
test,
// Onbekend telt als toegestaan: eerst moeten testen om te mogen
// verbinden zou betekenen dat je altijd een poging in de weigerlijst
// van de relay zet voordat je iets kunt doen.
magVerbinden: test === null || test.geaccepteerd === true,
});
}
return { relayUrl: config.relayUrl, eigenaars, log };
};
// ── De afhandeling ──────────────────────────────────────────────────────────
const acties = {
nieuw: async ({ naam }) => {
const owner = nieuweEigenaar();
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
meld(`${regel.naam} aangemaakt, OwnerId ${regel.ownerId}`);
return { naam: regel.naam, ownerId: regel.ownerId };
},
verbind: async ({ naam }) => {
await verbind(naam, true);
return { verbonden: true };
},
verbreek: async ({ naam }) => {
await verbreek(naam);
return { verbonden: false };
},
schrijf: async ({ naam, label, bytes }) => {
const omvang = Number.parseInt(bytes ?? '64', 10);
if (!Number.isInteger(omvang) || omvang < 1 || omvang > 8 * 1024 * 1024) {
throw new Error('bytes moet tussen 1 en 8388608 liggen.');
}
// Schrijven opent zo nodig zelf een verbinding, dus dezelfde poort als bij
// de knop "Verbinden": een geweigerde eigenaar gaat lokaal verder in
// plaats van de weigerlijst van de relay te vullen.
const test = laatsteTest.get(naam);
const geweigerd = test != null && test.geaccepteerd === false;
const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl) && !geweigerd));
const body = new Uint8Array(omvang);
for (let i = 0; i < omvang; i += 1) body[i] = i % 256;
const geschreven = await ingang.store.schrijf(label || 'blob', body);
meld(`${naam}: blob van ${omvang} bytes weggeschreven (${geschreven.id})`);
return { id: geschreven.id, bytes: omvang };
},
lees: async ({ naam }) => {
const ingang = open.get(naam) ?? (await verbind(naam, false));
const rijen = await ingang.store.lees();
return {
rijen: rijen.map((rij) => ({
id: rij.id,
label: rij.label,
bytes: rij.body?.length ?? 0,
})),
};
},
test: async ({ naam }) => {
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (!config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
meld(`${naam}: testen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`);
return uitkomst;
},
vergeet: async ({ naam }) => {
await verbreek(naam);
laatsteTest.delete(naam);
const weg = register.vergeet(naam);
meld(`${weg.naam} uit het register gehaald`);
return { naam: weg.naam };
},
};
const leesBody = (verzoek) =>
new Promise((klaar, faal) => {
let ruw = '';
verzoek.on('data', (stuk) => {
ruw += stuk;
if (ruw.length > MAX_BODY_BYTES) {
faal(new Error('Verzoek te groot.'));
verzoek.destroy();
}
});
verzoek.on('end', () => {
try {
klaar(ruw === '' ? {} : JSON.parse(ruw));
} catch (oorzaak) {
faal(new Error(`Onleesbare JSON: ${oorzaak.message}`));
}
});
verzoek.on('error', faal);
});
const stuur = (antwoord, code, waarde) => {
const inhoud = JSON.stringify(waarde);
antwoord.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(inhoud),
});
antwoord.end(inhoud);
};
const server = createServer((verzoek, antwoord) => {
void (async () => {
const pad = (verzoek.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/';
if (verzoek.method === 'GET' && (pad === '/' || pad === '/index.html')) {
// Elke keer van schijf. Dit is gereedschap op je eigen machine, dus een
// pagina bijwerken en verversen hoort te werken zonder herstart.
const pagina = readFileSync(join(HIER, 'index.html'));
antwoord.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': pagina.length,
});
antwoord.end(pagina);
return;
}
if (verzoek.method === 'GET' && pad === '/api/status') {
stuur(antwoord, 200, await status());
return;
}
if (verzoek.method === 'POST' && pad.startsWith('/api/')) {
const actie = acties[pad.slice('/api/'.length)];
if (!actie) {
stuur(antwoord, 404, { fout: 'Onbekende actie.' });
return;
}
try {
stuur(antwoord, 200, await actie(await leesBody(verzoek)));
} catch (oorzaak) {
// 400 en niet 500: vrijwel alles wat hier misgaat is een naam die al
// bestaat of een relay die er niet is, en dat is geen serverfout.
stuur(antwoord, 400, { fout: oorzaak.message });
}
return;
}
stuur(antwoord, 404, { fout: 'Niet gevonden.' });
})();
});
await new Promise((klaar) => {
server.listen(config.uiPoort, '127.0.0.1', klaar);
});
const adres = `http://127.0.0.1:${config.uiPoort}`;
console.log('');
console.log(` Bedieningsvlak: ${adres}`);
console.log(` Relay: ${config.relayUrl ?? 'niet ingesteld (zie .env.sample)'}`);
console.log(` Register: ${register.pad}`);
console.log('');
console.log(' Ctrl-C om te stoppen.');
console.log('');
const stop = async () => {
console.log('');
console.log('Afsluiten...');
server.close();
for (const naam of [...open.keys()]) await verbreek(naam);
process.exit(0);
};
process.on('SIGINT', () => {
void stop();
});
await new Promise(() => {});
};
+193
View File
@@ -0,0 +1,193 @@
// ═══════════════════════════════════════════════════════════════════════════════
// release-bump.mjs: zet een nieuwe image-tag met digest in de compose van een app
// en verhoogt `version` in zijn manifest.
//
// Draaien: node tools/release-bump.mjs <app-id> <image-versie> <digest>
// Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...
//
// WAAROM DIT BESTAAT. De release-pijplijn in de AssetApp-repo bouwt, duwt en leest
// de digest terug, maar werkt deze repo bewust NIET bij: een token dat daar staat en
// hier mag schrijven, kan alles naar de store duwen die umbreld anoniem kloont. Wat
// overbleef was overtypwerk, en juist daar zit de stille fout: een OUDE digest onder
// een NIEUWE tag levert de oude image, zonder melding. Dit script vervangt de hele
// `image:`-regel, dus die combinatie kan niet meer ontstaan.
//
// Lokaal draaien en zelf de diff lezen is het ontwerp en geen tussenoplossing. Het
// script pusht niets.
//
// DE TWEE NUMMERS ZIJN NIET HETZELFDE. De tag is de versie van de IMAGE, `version`
// in het manifest die van de APP, en die tweede moet bij élke wijziging omhoog, ook
// als alleen de compose verandert, anders rolt umbrelOS niets uit. Daarom kiest dit
// script hem expliciet en drukt af wat hij koos.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const HOST = 'sc.kamenier-hamer.nl';
// ── De twee herschrijvingen: puur, want daar gaan de tests over ────────────────
const DIGEST_VORM = /^sha256:[0-9a-f]{64}$/;
const VERSIE_VORM = /^\d+\.\d+\.\d+$/;
// Eén `image:`-regel van déze host. Een regel van een andere host blijft staan: een
// app mag een image van buiten gebruiken en die hoort hier niet mee te verschuiven.
function imageRegels(tekst) {
const regels = tekst.split(/\r?\n/);
const treffers = [];
regels.forEach((regel, i) => {
const m = regel.match(new RegExp(`^(\\s*image:\\s*)(${HOST}/[^\\s:@]+)(?::[^\\s@]+)?(?:@sha256:[0-9a-f]+)?\\s*$`));
if (m) treffers.push({ i, inspring: m[1], naam: m[2] });
});
return treffers;
}
// Vervangt de HELE regel en plakt er niets bij. Dat is de kern: een oude digest kan
// zo niet blijven staan. Meerdere containers uit hetzelfde image gaan samen mee, want
// twee versies naast elkaar in één compose is nooit de bedoeling.
export function herschrijfImage(tekst, versie, digest) {
if (!VERSIE_VORM.test(versie)) throw new Error(`versie heeft niet de vorm x.y.z: ${versie}`);
if (!DIGEST_VORM.test(digest)) throw new Error(`digest heeft niet de vorm sha256: plus 64 hex: ${digest}`);
const treffers = imageRegels(tekst);
if (treffers.length === 0) throw new Error(`geen image-regel van ${HOST} gevonden`);
const namen = [...new Set(treffers.map((t) => t.naam))];
if (namen.length > 1) throw new Error(`meerdere eigen images in één compose: ${namen.join(', ')}`);
const regels = tekst.split(/\r?\n/);
for (const t of treffers) regels[t.i] = `${t.inspring}${t.naam}:${versie}@${digest}`;
return { tekst: regels.join('\n'), naam: namen[0], aantal: treffers.length };
}
export function huidigeAppVersie(manifest) {
const m = manifest.match(/^version:\s*"?(\d+\.\d+\.\d+)"?\s*$/m);
if (!m) throw new Error('geen `version:` met de vorm x.y.z in het manifest');
return m[1];
}
// Hoger is hoger, per deel en niet als tekst: "0.10.0" hoort boven "0.9.0" te staan.
export function isHoger(a, b) {
const [a1, a2, a3] = a.split('.').map(Number);
const [b1, b2, b3] = b.split('.').map(Number);
return a1 !== b1 ? a1 > b1 : a2 !== b2 ? a2 > b2 : a3 > b3;
}
// De app-versie is niet de image-versie. Ligt de image erboven, dan is gelijktrekken
// het duidelijkst; ligt hij eronder of gelijk, dan is alleen de patch omhoog genoeg om
// umbrelOS te laten uitrollen. Beide gevallen worden afgedrukt, want dit is precies het
// soort keuze dat je een maand later niet meer terugziet.
export function kiesAppVersie(huidig, imageVersie) {
if (isHoger(imageVersie, huidig)) return imageVersie;
const [groot, klein, patch] = huidig.split('.').map(Number);
return `${groot}.${klein}.${patch + 1}`;
}
export function herschrijfVersie(manifest, nieuw) {
if (!VERSIE_VORM.test(nieuw)) throw new Error(`versie heeft niet de vorm x.y.z: ${nieuw}`);
const regels = manifest.split(/\r?\n/);
const i = regels.findIndex((r) => /^version:\s*"?\d+\.\d+\.\d+"?\s*$/.test(r));
if (i === -1) throw new Error('geen `version:` met de vorm x.y.z in het manifest');
regels[i] = `version: "${nieuw}"`;
return regels.join('\n');
}
// ── De controle bij het register ──────────────────────────────────────────────
// Hoort die digest wel bij die tag? Dat is de enige vraag die dit script zelf niet
// kan beantwoorden, en het is de vraag waar de stille fout zit. Anoniem, want zo
// haalt umbreld hem straks ook: faalt dit met een 401, dan staat het pakket op privé
// en zou de installatie op het apparaat struikelen.
const MEDIA = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', ');
export async function digestBijTag(naam, versie) {
const pad = naam.slice(HOST.length + 1);
const url = `https://${HOST}/v2/${pad}/manifests/${versie}`;
let antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA } });
// Het register vraagt ook voor een openbaar pakket een token, en dat token haal je
// zonder inloggegevens op. Slaagt dát niet, dan is het pakket niet openbaar.
if (antwoord.status === 401) {
const uitdaging = antwoord.headers.get('www-authenticate') || '';
const veld = (naam) => (uitdaging.match(new RegExp(`${naam}="([^"]+)"`)) || [])[1];
const realm = veld('realm');
if (!realm) throw new Error(`register vraagt om inloggegevens en zegt niet waar: ${uitdaging}`);
const tokenUrl = new URL(realm);
if (veld('service')) tokenUrl.searchParams.set('service', veld('service'));
tokenUrl.searchParams.set('scope', veld('scope') || `repository:${pad}:pull`);
const tokenAntwoord = await fetch(tokenUrl);
if (!tokenAntwoord.ok) throw new Error(`anoniem token weigeren (${tokenAntwoord.status}): staat het pakket op privé?`);
const { token } = await tokenAntwoord.json();
antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA, Authorization: `Bearer ${token}` } });
}
if (antwoord.status === 404) throw new Error(`tag ${versie} bestaat niet in het register`);
if (!antwoord.ok) throw new Error(`register antwoordt ${antwoord.status} op ${url}`);
const digest = antwoord.headers.get('docker-content-digest');
if (!digest) throw new Error('register geeft geen docker-content-digest terug');
return digest;
}
// ── De aanroep ────────────────────────────────────────────────────────────────
// `process.exitCode` en NOOIT `process.exit()`: die tweede kapt af terwijl de fetch
// naar het register nog open staat, en op Windows sterft Node dan in libuv met code 127
// in plaats van de code die je bedoelde. Zo eindigt een nette weigering als een crash.
async function main(argv) {
const vlaggen = argv.filter((a) => a.startsWith('--'));
const [app, versie, digest] = argv.filter((a) => !a.startsWith('--'));
if (!app || !versie || !digest) {
console.error('Gebruik: node tools/release-bump.mjs <app-id> <image-versie> <digest> [--offline]');
console.error('Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...');
process.exitCode = 2;
return;
}
const composePad = join(app, 'docker-compose.yml');
const manifestPad = join(app, 'umbrel-app.yml');
const compose = readFileSync(composePad, 'utf8');
const manifest = readFileSync(manifestPad, 'utf8');
const { tekst: nieuweCompose, naam, aantal } = herschrijfImage(compose, versie, digest);
if (!vlaggen.includes('--offline')) {
const echt = await digestBijTag(naam, versie);
if (echt !== digest) {
console.error(`De digest hoort NIET bij tag ${versie}.`);
console.error(` gegeven: ${digest}`);
console.error(` register: ${echt}`);
console.error('Er is niets gewijzigd. Dit is precies de verwisseling die stil de oude image oplevert.');
process.exitCode = 1;
return;
}
}
const huidig = huidigeAppVersie(manifest);
const appVersie = kiesAppVersie(huidig, versie);
const nieuwManifest = herschrijfVersie(manifest, appVersie);
writeFileSync(composePad, nieuweCompose);
writeFileSync(manifestPad, nieuwManifest);
console.log(`${composePad}: ${naam}:${versie}@${digest}${aantal > 1 ? ` (${aantal} regels)` : ''}`);
console.log(`${manifestPad}: version ${huidig} → ${appVersie}`);
console.log(vlaggen.includes('--offline') ? 'Digest NIET gecontroleerd (--offline).' : 'Digest komt overeen met wat het register onder die tag heeft.');
console.log('Niets gepusht. Lees de diff en commit zelf.');
}
// `pathToFileURL` en geen zelfgebouwde `file://`-string: op Windows begint die met een
// schijfletter en dan komt er één schuine streep te weinig uit, waardoor dit bestand
// als module wél laadt maar nooit iets doet.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2)).catch((fout) => {
console.error(String(fout.message || fout));
process.exitCode = 1;
});
}
+193
View File
@@ -0,0 +1,193 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Maakt van een statuspagina een versie die je in een browser kunt openen.
//
// Draaien:
//
// node tools/voorbeeldpagina.mjs
// node tools/voorbeeldpagina.mjs whatsnext-electrum-gate
//
// Het resultaat komt in `voorbeeld/<app>.html` en die map is gitignored.
//
// ── Waarom dit bestaat ───────────────────────────────────────────────────────
//
// Een statuspagina is niet zomaar te openen. Twee dingen staan in de weg, en ze
// zijn allebei fundamenteel en niet op te lossen door er anders naar te kijken:
//
// 1. in een `index.html.template` staan accolade-variabelen die umbreld invult.
// Onopgelost staat er letterlijk "v${APP_VERSION}" in de kop. Een pagina die
// in een eigen image zit heeft dit niet meer, maar haalt de versie dan uit
// status.json, dus zonder agent staat er niets;
// 2. de pagina haalt al zijn gegevens bij een agent die alleen in de app bestaat.
// Zonder die agent zie je één foutmelding en verder een pagina vol "unknown":
// geen lijsten, geen teller, geen knoppen.
//
// Dit script vult het eerste in en maakt het tweede na. Wat je dan ziet is de
// opmaak met plausibele gegevens erin, en dat is genoeg om te beoordelen of de
// twee apps naast elkaar hetzelfde ontwerp zijn.
//
// ── Wat het NIET is ──────────────────────────────────────────────────────────
//
// Geen test en geen bewijs. De gegevens zijn verzonnen, dus dit zegt niets over
// of de pagina de echte status juist weergeeft; dat blijft handwerk op het
// apparaat. Wat er wél mee te vinden is: alles wat met opmaak te maken heeft, en
// dat is precies waar deze pagina's steeds op aangepast worden.
//
// Voor de fouten die je niet ziet staan er twee toetsen: `test_paginas_parsen.mjs`
// (loopt het script) en de dollarteken-toets in `test_appstore_vorm.py` (blijft
// het script heel na de invulling).
// ═══════════════════════════════════════════════════════════════════════════════
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HIER = dirname(fileURLToPath(import.meta.url));
const REPO = join(HIER, '..');
const UIT = join(REPO, 'voorbeeld');
const app = process.argv[2] || 'whatsnext-evolu-relay';
// De pagina staat op een van twee plekken: als *.template in de app-map, of als
// gewoon bestand in het bouwrecept onder tools/ als hij in een eigen image zit.
// Electrum Gate is sinds 0.1.0 het tweede geval. Alleen de eerste gaat door
// envsubst; voor de tweede is de invulling hieronder een lege stap.
const template = join(REPO, app, 'index.html.template');
const kort = app.includes('-') ? app.slice(app.indexOf('-') + 1) : app;
const inImage = join(REPO, 'tools', kort, 'index.html');
const bron = existsSync(template) ? template : inImage;
if (!existsSync(bron)) {
console.error(`FOUT: geen pagina gevonden op ${template} of ${inImage}`);
process.exit(1);
}
// De variabelen die umbreld invult. Alleen wat de pagina's gebruiken; de rest zou
// op het apparaat leeg worden en dat gebeurt hieronder ook.
const WAARDEN = {
APP_VERSION: leesVersie(app),
};
function leesVersie(appId) {
const manifest = readFileSync(join(REPO, appId, 'umbrel-app.yml'), 'utf8');
for (const regel of manifest.split('\n')) {
if (regel.startsWith('version:')) {
return regel.split(':')[1].trim().replace(/['"]/g, '');
}
}
return '0.0.0';
}
// ── De nagemaakte status van Evolu Relay ──────────────────────────────────────
// Met opzet niet de gelukkige gevallen alleen: er staat een eigenaar met en zonder
// label, een geblokkeerde, twee geweigerde pogingen waarvan één met veel pogingen,
// en de teller loopt. Dat is de drukste toestand die de pagina kan hebben, en
// daarin vind je de opmaakfouten.
function relayStatus(nu) {
const iso = (msVanaf) => new Date(nu + msVanaf).toISOString();
return {
version: leesVersie('whatsnext-evolu-relay'),
relay: { reachable: true, checked: iso(-5000), publicPort: 3852 },
owners: {
problem: null,
learning: true,
learningUntil: iso(97000),
learningSecondsLeft: 97,
allowed: [
{
id: 'k7Qw2mZp9RtY4bVn6XsL1cHgJdFaEuOi',
allowed: true,
label: 'Trezor Suite, hoofdwallet',
firstSeen: iso(-86400000 * 2),
lastSeen: iso(-120000),
},
{
id: 'p3Ne8UyTr5WqAzXc2VbNm9KlJhGfDsAq',
allowed: true,
label: null,
firstSeen: iso(-3600000),
lastSeen: iso(-45000),
},
],
blocked: [
{
id: 'zZ9YyXxWwVvUuTtSsRrQqPpOoNnMmLlK',
allowed: false,
label: 'Oude telefoon',
firstSeen: iso(-86400000 * 9),
lastSeen: iso(-86400000 * 3),
},
],
rejected: [
{
id: 'aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV',
label: null,
firstSeen: iso(-1800000),
lastSeen: iso(-30000),
attempts: 7,
},
{
id: 'QqWwEeRrTtYyUuIiOoPpAaSsDdFfGgHh',
label: 'Laptop van de buren?',
firstSeen: iso(-600000),
lastSeen: iso(-600000),
attempts: 1,
},
],
},
database: { bytes: 2374144, modified: iso(-120000) },
pendingCommand: false,
};
}
let html = readFileSync(bron, 'utf8');
// Doet na wat envsubst doet: bekende variabelen krijgen hun waarde, onbekende
// worden leeg. Dat laatste is geen slordigheid maar het gedrag op het apparaat.
html = html.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (heel, naam) =>
Object.prototype.hasOwnProperty.call(WAARDEN, naam) ? WAARDEN[naam] : '',
);
// Het icoon staat naast de template en niet naast het resultaat.
html = html.replace(
/src="icon\.png"/g,
`src="${join(REPO, app, 'icon.png').replace(/\\/g, '/')}"`,
);
// De agent nabootsen. Vóór het eigen script van de pagina, want dat begint meteen
// met een ronde. Alleen voor Evolu Relay; Electrum Gate leest een ander bestand en
// daarvoor is dit script (nog) niet uitgebreid, dus die pagina toont zijn eigen
// melding dat er geen gegevens zijn. Dat is genoeg voor de kop en de dialoog.
if (app === 'whatsnext-evolu-relay') {
const stub = [
'<script>',
'(function () {',
` var status = ${JSON.stringify(relayStatus(Date.now()))};`,
' window.fetch = function (url, opties) {',
" if (String(url).indexOf('status') !== -1) {",
' return Promise.resolve({ ok: true, json: function () { return Promise.resolve(status); } });',
' }',
' return Promise.resolve({ ok: true, json: function () { return Promise.resolve({}); } });',
' };',
'})();',
'</script>',
].join('\n');
const anker = '<script>\n(function () {';
if (html.indexOf(anker) === -1) {
// Hard falen en niet stil doorgaan: zonder de nabootsing is het resultaat een
// pagina vol "unknown", en dan denk je dat je naar de opmaak kijkt terwijl je
// naar een foutmelding kijkt.
console.error('FOUT: het ankerpunt voor het eigen script is niet gevonden');
process.exit(1);
}
html = html.replace(anker, `${stub}\n${anker}`);
}
mkdirSync(UIT, { recursive: true });
const doel = join(UIT, `${app}.html`);
writeFileSync(doel, html, 'utf8');
console.log(`Geschreven: ${doel}`);
console.log('');
console.log('Openen kan met de voorbeeldserver uit .claude/launch.json, of door het');
console.log('bestand rechtstreeks in een browser te slepen.');
+2
View File
@@ -0,0 +1,2 @@
id: whatsnext
name: WhatsNext?
Whitespace-only changes.
@@ -0,0 +1,77 @@
# ═══════════════════════════════════════════════════════════════════════════════
# Atreides Assets - je eigen vermogensoverzicht (crypto en edelmetaal), lokaal.
#
# Eén container: de Node-backend serveert de SPA én bezit de database. Het recept
# staat NIET in deze repo maar in `HomeGit/AtreidesAssets` (de Dockerfile in de root
# daar); deze store draagt alleen de app-definitie. Zie het plan Distributie,
# PLAN.md §5a: de scheiding loopt tussen repo's en niet tussen mappen.
#
# Geen `ports:` hier, en dat is het verschil met de andere twee apps in deze
# store. Die publiceren een eigen poort omdat een cliënt zónder browsersessie
# erbij moet (een wallet, een sync-cliënt). Dit is een web-UI en niets anders,
# dus hij hangt volledig achter app_proxy en umbrelOS bepaalt de poort.
# ═══════════════════════════════════════════════════════════════════════════════
services:
# umbrelOS genereert deze service zelf; wij vullen alleen in waar hij heen moet
# wijzen. De hostnaam heeft de vorm <app-id>_<compose-service>_1.
#
# PROXY_AUTH_ADD blijft weg, dus de inlog van umbrelOS staat ervoor. Dat is hier
# de bedoeling: dit is een administratie en niet een dienst voor een cliënt.
app_proxy:
environment:
APP_HOST: whatsnext-atreides-assets_server_1
APP_PORT: 8080
server:
# 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 14-09-2026.
#
# DE TAG IS DE VERSIE VAN DE IMAGE EN NIET DIE VAN DE APP. `version` in
# umbrel-app.yml loopt erop vooruit, want dat moet bij élke wijziging omhoog,
# ook als alleen dit bestand verandert; de tag alleen als er een nieuwe image
# is. Vandaar 0.1.1 in het manifest naast 0.1.0 hier.
#
# Er hoort geen build.sh bij deze app, want het recept woont in de
# AssetApp-repo; de toets in tests/test_appstore_vorm.py slaat de
# tag-vergelijking daarom over.
#
# Bij een verhoging: eerst de digest hier wéghalen, dan bouwen en duwen, dan
# de nieuwe erin. Een OUDE digest onder een NIEUWE tag levert stilzwijgend de
# oude image.
image: sc.kamenier-hamer.nl/sysop/atreides-assets:0.2.0@sha256:646fe7a169d9da0bad0df43e249237c88c7641d6c88f52cdd984b88c562d2859
restart: on-failure
# Geen `user:` nodig: de image zet zelf `USER node`. Dat is anders dan bij
# Evolu Relay, waar de image er geen heeft en de compose het per container
# regelt.
environment:
# ZONDER DIT IS DE APP ONBEREIKBAAR. De server bindt op 127.0.0.1 zolang TLS
# én inloggegevens ontbreken, en in een container is dat de loopback van de
# container: app_proxy komt er dan niet bij. De netwerkgrens is hier de
# afwezigheid van een `ports:`-regel plus de proxy, en niet de app zelf.
ASSETAPP_HOST: "0.0.0.0"
# HIER STAAT MET OPZET GEEN `ASSETAPP_PASSWORD`, en dat is een correctie op
# 0.1.0 (14-09-2026). Daar stond `${APP_PASSWORD}`, en dat gaf twee
# problemen tegelijk:
#
# 1. umbrelOS vult die variabele alleen aan een app die er in zijn
# manifest om vraagt, dus hij kwam leeg binnen en de app draaide
# zónder eigen slot;
# 2. en gevuld was ook niet goed geweest. De app leest de omgeving vóór
# het geheimenbestand, dus verbergt hij op Systeem -> Portefeuilles de
# knop om het wachtwoord te wijzigen. Het is dan alleen te veranderen
# door dit bestand aan te passen, en dat is voor een Umbrel-app de
# verkeerde plek.
#
# Het wachtwoord hoort dus in de portefeuille zelf, te zetten op
# Systeem -> Portefeuilles. Daar is het te wijzigen en het gaat mee in de
# back-up van umbrelOS. Tot je het zet, is de inlog van umbrelOS het enige
# slot, en dat beschermt niet tegen een andere app op hetzelfde
# Docker-netwerk.
volumes:
# De INSTALLATIEMAP van de app: active.json, tls/ en één map per
# portefeuille. Alles wat de gebruiker bezit staat hieronder, dus dit is de
# map die in de back-up van umbrelOS hoort.
- ${APP_DATA_DIR}/data/app:/data
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+97
View File
@@ -0,0 +1,97 @@
# De veldvolgorde hieronder is niet vrij: de packaging-documentatie van de
# officiele appstore schrijft hem voor. Zie de tabel in
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md, en de toets in
# tests/test_appstore_vorm.py die hem vasthoudt.
#
# Wat de spec niet noemt, staat onderaan dit bestand en niet ertussen. Dan is de
# voorgeschreven kop letterlijk goed en hoeft er bij inlevering alleen iets weg.
manifestVersion: 1
id: whatsnext-atreides-assets
category: finance
name: Atreides Assets
version: "0.2.0"
tagline: What you own, on your own machine
description: >-
A portfolio that nobody else gets to read. Every exchange dashboard and every tracking app
answers to someone: it knows what you hold, what you bought it for, and when you moved it.
This one runs on your Umbrel, keeps its database in your app folder, and talks to nothing
you did not point it at.
It tracks crypto and precious metals side by side, in one total. Balances come from two
places and it keeps them apart on purpose: what a wallet actually holds on chain, and what
your own bookkeeping says should be there. When those two drift, the app says so instead of
picking a winner, and shows you the entries behind the difference.
Prices come from providers you choose and configure yourself, with your own API keys. There
is no built-in account, no phone-home, and no analytics. The keys stay on the server side
and never reach your browser.
What it does beyond a list of holdings: a wealth chart over any year you have data for, the
figures you need for the Dutch box 3 declaration, signal charts for those who want them,
and a dashboard you arrange yourself. A Lightning wallet can be connected over NWC or LND
to read its balance.
It also speaks MCP, so an AI assistant you already use can read the administration and
propose entries. Proposals land in a queue and nothing is written until you approve it, and
what the assistant was shown is logged per call.
Your data is a directory: a database file and a secrets file per portfolio. Back it up,
move it to another machine, or keep several administrations side by side and switch between
them. There is no export you have to ask for.
releaseNotes: >-
The first update with real features since the app came to umbrelOS. It upgrades the database
on first start, so make sure your backup is recent before you update.
Every portfolio now has its own base currency, chosen when you create it. Amounts and prices
are shown in that currency throughout, and sources that quote in another currency are
converted on the way in.
Every price now shows where it came from: hover over an amount to see the source and the
date of the price behind it. A booking records the amount you actually paid next to the price
per unit, and a transfer can be marked as a gift. Gaps in the price history are shown per
asset and can be filled by hand or ignored. You choose a price source per asset, and the app
keeps each source within its monthly limit. Eulerpool is added as a source.
The wealth page shows return, realised and unrealised result, and interest and rewards as
income, with an overview per year. Events has a new Accounts tab with the current balance of
every account. Assets can carry notes. System, Health checks the data for problems you can
resolve or accept.
A second look called Glass sits next to the existing one, with new accent colours and a
reworked settings page. The AI connection (MCP) can now also read price data and propose a
price source.
developer: "WhatsNext?"
website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
dependencies: []
repo: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
support: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/issues
# De poort waarop umbrelOS de web-UI aanbiedt. 3850 en 3851 zijn bezet door de
# twee andere apps in deze store, 3852 is de eigen poort van de relay.
port: 3853
# Leeg laten bij een nieuw pakket; de plaatjes doet het store-team zelf.
gallery: []
path: ""
submitter: "WhatsNext?"
submission: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
# ── Hieronder staat wat de voorgeschreven volgorde niet noemt ────────────────
#
# GEEN defaultPassword, en dat is een correctie op 0.1.0. Deze app heeft wél een
# eigen inlog, maar die hoort bij de portefeuille en niet bij de installatie: je
# zet hem op Systeem -> Portefeuilles, hij is daar te wijzigen en hij gaat mee in
# de back-up. Een wachtwoord uit de omgeving zou dat scherm stil onwerkzaam maken,
# want de app leest de omgeving vóór het geheimenbestand.
# Het icoon hoort bij een eigen store en moet bij inlevering in de officiele
# store juist weg: die host iconen apart. Daarom staat het als laatste, want dan
# is dit de enige regel die eruit moet en blijft de rest letterlijk op orde.
icon: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/raw/branch/main/whatsnext-atreides-assets/icon.png
Whitespace-only changes.
Whitespace-only changes.
+136
View File
@@ -0,0 +1,136 @@
services:
# umbrelOS genereert deze service zelf; wij vullen alleen in waar hij heen moet
# wijzen. De hostnaam heeft de vorm <app-id>_<compose-service>_1.
app_proxy:
environment:
APP_HOST: whatsnext-electrum-gate_server_1
APP_PORT: 80
# Beide containers draaien dezelfde image, gebouwd door tools/electrum-gate/
# build.sh. Wat erin zit staat in de Dockerfile daar: nginx met de pagina en het
# stream-blok, en python3 met de agent. Tot 0.0.29 stonden die bestanden hier
# als *.template en draaiden ze op python:3-alpine en nginx:alpine; waarom dat
# veranderd is staat in het plan Eigenimage, PLAN.md §3.
#
# De tag hoort gelijk te zijn aan VERSION in build.sh, en er hoort een digest
# achter zodra de image geduwd is. Let op de valstrik uit Kennis.md: een
# oude digest onder een nieuwe tag levert stilzwijgend de oude image, dus laat
# de digest er even van af zolang de nieuwe niet geduwd is.
# De agent. Hij schrijft status.json, leest de certificaten, bevraagt de
# Electrum-server en neemt de certificaatkeuze aan.
#
# Waarom een tweede container en niet een lus in de server hieronder: dit werk
# is een programma. Een certificaatdatum uitlezen, een JSON-RPC-verzoek doen,
# een geschiedenis bijhouden en een keuze valideren zijn geen dingen die je met
# openssl en nc aan elkaar knoopt zonder dat het stil verkeerde antwoorden gaat
# geven.
agent:
image: sc.kamenier-hamer.nl/sysop/electrum-gate:0.1.0@sha256:97fee09ba8004c2f0cf55e03cb612474f0a4b24724f9186a4e6769cebb7cfd8f
restart: on-failure
command:
- python3
- /app/agent.py
environment:
# Het adres van de Electrum-server die de gebruiker in umbrelOS gekozen
# heeft. umbrelOS vult dit in op grond van de afhankelijkheid hieronder,
# dus dit werkt met Electrs, Fulcrum en ElectrumX.
GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}
GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}
GATE_TLS_PORT: "50022"
# De versie uit het manifest, voor de kop van de pagina. Die stond tot
# 0.0.29 rechtstreeks in de pagina, ingevuld door umbreld; nu de pagina in
# de image zit, geeft de agent hem door via status.json.
GATE_APP_VERSION: ${APP_VERSION}
# De zelfcontrole: de agent verbindt met de TLS-poort van de server
# hieronder en maakt de handdruk af. Niet localhost: dat is een andere
# container en dus een andere netwerk-namespace.
#
# De volledige containernaam en niet de servicenaam 'server'. Dat stond er
# eerst, en op het apparaat gaf het `[Errno 111] Connection refused`
# (27-08-2026). Die fout is veelzeggend: geweigerd betekent dat de naam wél
# oploste, alleen niet naar ons. 'server' is een naam die meer apps op deze
# machine gebruiken, en op een gedeeld netwerk is het dus een gok wie je
# krijgt. De vorm <app-id>_<service>_1 is uniek per app; het is niet toevallig
# dezelfde vorm die umbrelOS hierboven van APP_HOST verlangt.
GATE_TLS_HOST: whatsnext-electrum-gate_server_1
# Niet elke ronde meten, want elke meting is voor nginx een gewone sessie
# en levert dus een regel in het activiteitenlog op. Vijf minuten is vaak
# genoeg voor een waarde die zelden verandert; na een certificaatwissel
# wordt er sowieso gemeten.
GATE_SELF_CHECK_INTERVAL: "300"
GATE_INTERVAL: "60"
GATE_API_PORT: "8000"
GATE_STATE_DIR: /var/lib/gate
# Bron-naam=map. Een bron die niet gemount is, bestaat niet en wordt
# overgeslagen; dat is de normale toestand voor een reverse proxy die de
# gebruiker niet draait.
GATE_CERT_SOURCES: "Zoraxy=/certs/zoraxy,Own folder=/certs/own"
# De enige beschrijfbare bron, en dus de map waarin een upload van de
# pagina landt. Expliciet en niet afgeleid uit de lijst hierboven: welke
# bron beschrijfbaar is, hoort te staan naast de mount die dat toestaat.
# Leeg zetten schakelt uploaden uit.
GATE_UPLOAD_DIR: /certs/own
volumes:
# De gedeelde toestand: status.json, cert.conf, de herlaadvlag en de
# gekozen certificaat-id. Beide containers zitten hierin.
#
# Onder data/, want dat is wat andere apps doen: electrs mount
# ${APP_DATA_DIR}/data/electrs, mempool ${APP_DATA_DIR}/data. Verplaatst op
# 20-08-2026 met het oog op publicatie in de officiele store.
- ${APP_DATA_DIR}/data/runtime:/var/lib/gate
# De certificaatbronnen. Zoraxy alleen lezen: dat zijn de certificaten van
# een andere app en die raakt deze app niet aan.
- ${UMBREL_ROOT}/app-data/zoraxy/data/config/conf/certs:/certs/zoraxy:ro
# De eigen map is de enige die beschrijfbaar is, want hier landt een upload
# van de pagina. Alleen de agent; de nginx-container houdt hem alleen-lezen,
# want die hoeft er nooit iets neer te zetten.
- ${APP_DATA_DIR}/data/certs:/certs/own
#
# Nginx Proxy Manager hoort hier als derde bron bij en staat er bewust nog
# niet in. Het pad hieronder is een gok en niet geverifieerd, en een
# bind-mount naar een pad dat niet bestaat laat Docker het aanmaken. Dat zou
# een lege maphierarchie neerzetten in de app-data van een app die
# misschien niet eens geinstalleerd is, en die rommel blijft daar staan.
# Eerst op de Umbrel controleren waar NPM zijn certificaten neerzet, dan
# deze regel aanzetten en de naam toevoegen aan GATE_CERT_SOURCES.
#
# - ${UMBREL_ROOT}/app-data/nginx-proxy-manager/data/letsencrypt/live:/certs/npm:ro
# nginx: de pagina op poort 80 achter de app_proxy, en de TLS-poort 50022 zodra
# de agent een certificaat gekozen heeft. Het startscript is entrypoint.sh in de
# image; tot 0.0.29 stond dat hier als command-blok van ruim honderd regels, met
# elk dollarteken verdubbeld. Wat het doet staat daar uitgelegd.
server:
image: sc.kamenier-hamer.nl/sysop/electrum-gate:0.1.0@sha256:97fee09ba8004c2f0cf55e03cb612474f0a4b24724f9186a4e6769cebb7cfd8f
restart: on-failure
# De agent moet er zijn voordat nginx start, want de proxy_pass naar de agent
# wordt bij het starten opgelost en een onbekende naam laat nginx afbreken.
# Op de cert.conf van de agent wordt niet gewacht: de pagina komt hoe dan ook
# omhoog, zie entrypoint.sh.
depends_on:
- agent
ports:
# De TLS-poort voor Electrum-wallets. Dit is de enige poort die deze app
# zelf publiceert; de web-UI loopt via app_proxy.
#
# 50022 en niet de conventionele 50002, omdat Fulcrum die op de host
# bezet: met Fulcrum als backend zou de container niet starten, en dat is
# precies het omschakelen dat deze app moet ondersteunen. De poort naar
# buiten is toch al een andere, want die staat in de router doorgestuurd.
# Beslist 19-08-2026, open punt 1 van het plan Configuratie.
- "50022:50022"
environment:
# Dezelfde twee als bij de agent. entrypoint.sh vult ze in het stream-blok
# in, want nginx kan in een proxy_pass geen omgevingsvariabele lezen. Tot
# 0.0.29 deed umbreld dat via stream.conf.template.
GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}
GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}
volumes:
# Dezelfde gedeelde toestand als de agent. nginx leest hier cert.conf en
# status.json, en schrijft de sessielog.
- ${APP_DATA_DIR}/data/runtime:/var/lib/gate
# De certificaten zelf moeten ook hier gemount zijn: de agent bepaalt
# welk pad in cert.conf komt, maar nginx moet het bestand kunnen openen.
- ${UMBREL_ROOT}/app-data/zoraxy/data/config/conf/certs:/certs/zoraxy:ro
- ${APP_DATA_DIR}/data/certs:/certs/own:ro
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+236
View File
@@ -0,0 +1,236 @@
# De veldvolgorde hieronder is niet vrij: de packaging-documentatie van de
# officiele appstore schrijft hem voor. Zie de tabel in
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md, en de toets in
# tests/test_server_start_zonder_certificaat.py die hem vasthoudt.
#
# Wat de spec niet noemt, staat onderaan dit bestand en niet ertussen. Dan is de
# voorgeschreven kop letterlijk goed en hoeft er bij inlevering alleen iets weg.
manifestVersion: 1
id: whatsnext-electrum-gate
category: bitcoin
name: Electrum Gate
version: "0.1.0"
tagline: Your own node from anywhere, without waiting for Tor
description: >-
The privacy win is already yours: you run the Electrum server. A public one gets asked for
the history of every address in your wallet, and that tells it which addresses and which
balance belong to one person. Your own server never reports back.
What is left is a trade. Tor is the more private way in, and every wallet below speaks it.
But it adds hundreds of milliseconds to every request, it drops when a phone sleeps or
changes network, and plenty of networks block it outright. Electrum Gate is the other side
of that trade: a TLS front door on your node. Fast, no fingerprint to type over, and it
works on any network you happen to be on.
It reuses the Let's Encrypt certificate your reverse proxy already manages, so there is
nothing to request and nothing to renew. A renewal is picked up on its own, without
dropping connections that are already open. You can also upload a certificate you manage
yourself, if you do not run a reverse proxy on this machine.
Nothing in the middle. The connection runs from your wallet straight to your own node,
encrypted with a certificate you already own, for a domain you already control. There is no
account to create, no tunnel service that terminates your traffic along the way, and no
client to install on every device you use: the wallets below already speak TLS, they only
need an address. What it does ask of you is one forwarded port on your router.
Useful for wallets connecting from outside your home: Trezor Suite, Electrum, Sparrow,
BlueWallet, Nunchuk, Blockstream and BitBoxApp. Apps on the Umbrel itself do not need
it, they already reach the Electrum server directly.
The dashboard hands you the exact line each of those wallets asks for, and shows how long
your certificate is still good for and whether your Electrum server is answering. Electrs,
Fulcrum and ElectrumX all work, switchable in the umbrelOS settings.
What it does not do: hide that the traffic exists. Your domain is public in the certificate
transparency logs. If that matters more to you than speed, use Tor instead.
# Eén verhaal over deze versie, dan één regel per eerdere versie. Niet meer dan
# dat, en dat staat hier omdat het drie versies achter elkaar fout ging: bij elke
# release kwam er een nieuwe kop bovenop terwijl de oude tekst eronder bleef
# staan. In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer
# dezelfde 0.0.4-regel. De toets let er nu op.
releaseNotes: >-
The app now ships as its own image instead of mounting its program files into two stock images. Nothing
changes in what it does; what changes is where the code lives. The app data folder now holds only your
data: the certificate you chose, the one you uploaded, and the status the page reads. The startup logic
that used to be a hundred lines of shell inside the compose file is a plain script inside the image, and
the app icon updates along with everything else, which it could not before.
Everything in 0.0.29:
The glow behind the page is gone. It arrived in 0.0.25 and it did what it was meant to do, but a status
page is something you glance at, and a coloured wash over the whole window is a lot of decoration for a
glance. The panels keep their edge, their shadow and their top light, so they still read as panels; only
the light behind them is off. The switch is still in the page for anyone who wants it back.
Everything in 0.0.28:
The block height tile is gone. This app is a front door for your Electrum server; what the chain is
doing is not its news to report, and every wallet and block explorer you own already tells you. Four
tiles are left, all of them about this gateway: which server your traffic goes to, whether it answers
and how fast, how long your certificate is still good for, and whether the front door itself is up.
How old the readings are now sits under the response time, where it used to sit under the height.
Everything in 0.0.27:
Fixes the Status card reading "Not answering" for five minutes after every restart or update, with
a connection error underneath, while the gateway itself was working fine the whole time. The check
runs from inside the app and it ran before the part it checks had started, so the very first
measurement of every restart was bound to fail, and nothing looked again for five minutes. It now
says Checking on that first round and takes a real measurement a minute later, and a failed
measurement is repeated within a minute instead of after five.
Everything in 0.0.26:
Two adjustments to the refresh. The Status card reads in the accent colour again when it says
Running, and on a phone the page drops back to the flat look: the glow is wider than a phone screen,
so it stopped being a gradient and became a haze over everything.
Earlier releases:
0.0.25 was a visual refresh shared with the other app in this store: translucent cards with a light edge
and a shadow, one corner radius for every framed element, and a glow behind the page. That glow is the
one this release switches off; the rest of it is still there.
0.0.24 added an "About this app" item to the menu, and changed the Status card from "Answering" with
"answered in 7 ms, from inside the app" underneath to "Running" with the measurement below it. The
caveat moved into that dialog, where there is room to state it: the check runs inside the app, so a
router that no longer forwards the port still reads as Running here.
0.0.23 fixed a page that could go blank or show stale values once another app from the same store was
installed. The page reached its helper by a short name that a second app happened to use as well, so
roughly half of its requests ended up at the wrong one.
0.0.22 gave panels in light mode an outline as well as a shadow. A soft shadow on white gives depth but
no edge, so where a panel ended was a matter of looking closely. Dark mode was unchanged.
0.0.21 put every tile value at the same size, so a block height no longer looked more important than a
server name beside it, and gave the dialog a cross to close instead of the word "Close".
0.0.20 made the five tiles a little taller, so their titles and values are no longer crowded together.
0.0.19 moved the text under each tile down to the bottom, level with the response graph beside it, and
put the arrow on "Setting up your wallet" to the left of the title where the browser would have put it.
0.0.18 fixed the Status tile reporting "Not answering" on a gateway that was working fine: the check was
connecting to the wrong container. It also made the menu three plain dots instead of a button, renamed
its first item to "Settings", opened the dialog in the middle of the screen instead of the top left,
made the tiles taller with their values along the bottom, swapped response time and block height, and
halved the height of the activity log.
0.0.17 rebuilt the dashboard around five small tiles, moved choosing and uploading certificates into a
dialog behind the menu at the top right, put the activity log across the full width, and collapsed
"Setting up your wallet" into a much shorter panel with two copy buttons instead of one per wallet.
0.0.16 taught the app to check its own front door: it connects to its own TLS port, completes the
handshake, and compares the certificate it gets back with the one you picked. That catches a
certificate change that never actually took effect, which looks fine from the outside and fails at your
wallet. It does not tell you whether your gateway can be reached from outside; the check runs inside the
app.
0.0.15 moved the app store this app comes from to a new address, and pointed every link in this listing
there. If you added the store at its old address, remove it in umbrelOS and add the new one.
0.0.14 stopped calling a scan from the internet a refusal. Those now read "probe", in grey, and red
is kept for a session that did carry traffic and then broke. The panels also got more room between
them.
0.0.13 made the certificate panel and the activity log grow and shrink together, so opening the
upload section no longer leaves a gap under the button.
0.0.12 tightened the spacing in the certificate panel, and dropped a line claiming certificates
are read from Nginx Proxy Manager.
0.0.11 lined up the copy buttons along the right edge on a phone, and moved the upload section
above the certificate picker.
0.0.10 made the dashboard work on a phone, and dropped the tile counting the days left on your
certificate.
0.0.9 moved everything the app writes into the "data" folder inside the app folder, which is
where the App Store expects an app to keep its state.
0.0.8 gave the dashboard the same line as this listing, instead of describing TLS as a means.
0.0.7 added uploading your own certificate, with the key checked against the certificate before
anything is stored.
0.0.6 made a connected wallet visible while it is connected, turned the certificate picker into
a dropdown, and rewrote the session line in the activity log.
0.0.5 shortened the message you get when more than one certificate was found.
0.0.4 fixed an install that showed no dashboard at all.
developer: "WhatsNext?"
website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
dependencies:
- electrs
repo: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
support: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/issues
# De poort waarop umbrelOS de web-UI van deze app aanbiedt, niet de TLS-poort.
# Die staat in docker-compose.yml en is 50022.
port: 3850
# Leeg laten bij een nieuw pakket; de plaatjes doet het store-team zelf.
gallery: []
path: ""
submitter: "WhatsNext?"
submission: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
# ── Hieronder staat wat de voorgeschreven volgorde niet noemt ────────────────
#
# defaultUsername en defaultPassword stonden hier met een lege waarde en zijn
# eruit. Deze app heeft geen eigen inlog; de app_proxy van umbrelOS zet er zijn
# eigen voor. Een leeg veld zegt niets en suggereert dat er iets te vullen valt.
# Wat niet in de back-up hoeft. Paden zijn relatief aan de app-datamap en
# ondersteunen een jokerteken; nagekeken in app.ts van umbreld op 20-08-2026.
#
# Alleen de twee die groeien of elke minuut veranderen. Wat er bewust NIET bij
# staat is data/runtime/config: daar zit de certificaatkeuze van de gebruiker, en
# die is het enige in deze map dat niet opnieuw te bedenken is.
backupIgnore:
- data/runtime/stream.log
- data/runtime/status.json
# Het icoon hoort bij een eigen store en moet bij inlevering in de officiele
# store juist weg: die host iconen apart. Daarom staat het als laatste, want dan
# is dit de enige regel die eruit moet en blijft de rest letterlijk op orde.
icon: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/raw/branch/main/whatsnext-electrum-gate/icon.png
Whitespace-only changes.
+137
View File
@@ -0,0 +1,137 @@
# ═══════════════════════════════════════════════════════════════════════════════
# Evolu Relay - je eigen sync-server voor apps die op Evolu gebouwd zijn.
#
# Drie containers: de relay, een agent voor de statuspagina, en nginx die die
# pagina serveert. Geen Postgres en geen quota-manager meer; die hoorden bij de
# relay van Trezor, en die is als zelf-gehoste relay in de kern onbruikbaar omdat
# de cliënt bij een eigen relay-URL nooit een eigenaar registreert. Zie het plan
# Umbrelapp, OPEN.md punt 6, en de proef in PLAN.md §6a.
#
# De poortindeling is omgedraaid ten opzichte van 25-08-2026, en dat is de kern
# van dit bestand:
#
# - de PAGINA hangt achter app_proxy, mét de inlog van umbrelOS. Vroeger stond de
# relay daar en moest de inlog dus uit, waardoor een statuspagina net zo
# onbeschermd zou zijn als de relay zelf;
# - de RELAY publiceert zijn eigen poort, waar Zoraxy met TLS naartoe wijst.
# een sync-cliënt is geen browser met een sessiecookie en zou achter de inlog
# een inlogpagina krijgen in plaats van de relay.
#
# Dat is hetzelfde patroon als Electrum Gate in deze store: de web-UI via de
# proxy, het protocol op een eigen poort. Zie PLAN.md §4h.
# ═══════════════════════════════════════════════════════════════════════════════
services:
# umbrelOS genereert deze service zelf; wij vullen alleen in waar hij heen moet
# wijzen. De hostnaam heeft de vorm <app-id>_<compose-service>_1.
#
# PROXY_AUTH_ADD staat hier bewust NIET op "false". Dat stond er tot 0.0.2 wel,
# toen de relay hierachter hing. Nu wijst dit naar de statuspagina, en die hoort
# juist achter de inlog. Zet het niet terug zonder §4h te lezen.
app_proxy:
environment:
APP_HOST: whatsnext-evolu-relay_server_1
APP_PORT: 80
# Alle drie de containers draaien dezelfde image, gebouwd uit tools/evolu-relay/
# door build.sh en geduwd naar het Gitea-register op dezelfde server als deze
# store. Dat moet: umbreld haalt élke image op via de Docker Engine API en niet
# via compose, dus een lokaal gebouwde tag is voor hem onbereikbaar en de
# installatie faalt met "pull access denied". Op 25-08-2026 op het apparaat
# vastgesteld.
#
# Tot 0.5.6 zat alleen de relay in die image en draaiden de agent en de pagina
# als *.template op python:3-alpine en nginx:alpine. Sinds 0.6.0 zit alles erin;
# zie het plan Eigenimage, PLAN.md §8.
#
# 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. Houd de tag
# gelijk aan VERSION in build.sh (drie keer hier) en `version` in het manifest.
#
# De keerzijde bij een verhoging: een OUDE digest onder een NIEUWE tag levert
# stilzwijgend de oude image. Bij 0.5.0 stond hier daarom even een tag zonder
# digest, zodat het hard faalde tot de image bestond, in plaats van stil de
# relay zonder tijdvenster te draaien. Doe dat weer zo bij elke verhoging: eerst
# de digest weg, dan bouwen en duwen, dan de nieuwe erin.
#
# Gebouwd op de Umbrel zelf, dus één architectuur (amd64). Voor de officiele
# store hoort er een multi-arch index-digest met arm64 in; zie het masterplan
# Publicatie-Relay.
relay:
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.1@sha256:a701032aae4aba8fd7acace0c0d6c900ad6ace32eee03762b36b00c7e8c0ce6c
restart: on-failure
# Als node en niet als root, zoals de image tot 0.5.6 zelf al deed met USER.
# Nu de image ook nginx en de agent draagt, staat er geen USER meer in en
# regelt de compose het per container; de relay is de enige die het niet
# nodig heeft. De map data/relay is van deze gebruiker.
user: node
ports:
# De relay zelf. Dit is de poort waar Zoraxy met TLS naartoe wijst, en de
# enige die deze app publiceert; de pagina loopt via app_proxy.
#
# 3852 op de host en niet 4000: dat laatste is een veelgebruikte poort en
# een botsing merk je pas als de app niet start. Dezelfde les als bij
# Electrum Gate, dat om die reden 50022 gebruikt in plaats van 50002.
# Binnen de container blijft het 4000, want dat is wat de relay verwacht.
- "3852:4000"
environment:
RELAY_PORT: "4000"
# Per schrijfactie en niet per eigenaar; zo is isOwnerWithinQuota bij Evolu
# bedoeld. Eén labelwijziging is klein, dus een schrijfactie hierboven is
# eerder een fout of misbruik dan normaal gebruik. Zie PLAN.md §4g.
RELAY_MAX_WRITE_BYTES: "1048576"
volumes:
# De database van de relay én onze allowlist ernaast, in dezelfde map. Het
# programma doet chdir naar `data`, net als de relay van Evolu zelf, dus dit
# pad ligt vast.
#
# Onder data/ en niet in een naamloos Docker-volume: dit is wat umbrelOS
# bewaart en meeneemt in de back-up. In een volume zou de synchronisatie een
# herinstallatie van de app niet overleven.
- ${APP_DATA_DIR}/data/relay:/app/data
# De agent bedient de statuspagina. Hij 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.
agent:
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.1@sha256:a701032aae4aba8fd7acace0c0d6c900ad6ace32eee03762b36b00c7e8c0ce6c
restart: on-failure
command:
- python3
- /app/agent.py
environment:
RELAY_STATE_DIR: /var/lib/relay
# De versie uit het manifest, voor de kop van de pagina. Die stond tot 0.5.6
# rechtstreeks in de pagina, ingevuld door umbreld; nu de pagina in de image
# zit, geeft de agent hem door via api/status.
RELAY_APP_VERSION: ${APP_VERSION}
# De volledige containernaam en niet de servicenaam 'relay'. Bij Electrum
# Gate gaf de korte naam op het apparaat een geweigerde verbinding: de naam
# loste wél op, maar naar een container van een andere app op hetzelfde
# netwerk. De vorm <app-id>_<service>_1 is uniek per app.
RELAY_HOST: whatsnext-evolu-relay_relay_1
RELAY_PORT: "4000"
# Alleen om op de pagina het adres te tonen dat je in je cliënt invult.
# Moet gelijk zijn aan de hostpoort hierboven.
RELAY_PUBLIC_PORT: "3852"
RELAY_API_PORT: "8000"
volumes:
# Dezelfde map als de relay. De agent moet hier kunnen schrijven, want de
# opdrachten van de pagina landen hier.
- ${APP_DATA_DIR}/data/relay:/var/lib/relay
# nginx met de pagina, uit dezelfde image. Geen mounts: pagina, configuratie en
# icoon zitten in de image, en de pagina heeft geen eigen staat.
server:
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.1@sha256:a701032aae4aba8fd7acace0c0d6c900ad6ace32eee03762b36b00c7e8c0ce6c
restart: on-failure
command:
- nginx
- -g
- daemon off;
# De agent moet er zijn voordat nginx start: de proxy_pass naar de agent wordt
# bij het starten opgelost en een onbekende naam laat nginx afbreken.
depends_on:
- agent
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+195
View File
@@ -0,0 +1,195 @@
# De veldvolgorde hieronder is niet vrij: de packaging-documentatie van de
# officiele appstore schrijft hem voor. Zie de tabel in
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md.
#
# Wat de spec niet noemt staat onderaan dit bestand en niet ertussen. Dan is de
# voorgeschreven kop letterlijk goed en hoeft er bij inlevering alleen iets weg.
#
# `dependencies` is weggelaten en niet leeg gezet: deze app hangt van geen enkele
# andere app af. Een leeg veld zou suggereren dat er iets te kiezen valt.
manifestVersion: 1
id: whatsnext-evolu-relay
category: files
name: Evolu Relay
version: "0.7.1"
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
connection. A relay is what lets a second device catch up: it holds the encrypted changes
until your other devices come online. This app runs that relay on your Umbrel, so the copy
in the middle sits on hardware you own.
It is the relay from the Evolu project, taken straight from the package they publish and not
reimplemented here. Any app built on Evolu can use it.
Your data is encrypted on your device before it leaves. The relay holds encrypted blobs,
padded so their size says nothing, and never has the key. What it can see is an owner id,
which identifies data without identifying you, along with timestamps and the addresses that
connect. Self-hosting does not make the data more secret than it already is; what it changes
is who keeps the encrypted copy, who can see that you are syncing at all, and whether anyone
can pull the service out from under you.
What this app adds is the part a self-hosted relay needs and a public one does not: control
over who may use it. New owners are accepted one at a time. The first one that connects is
remembered, and you close the door again once your own devices are paired; anything new is
refused until you open it. Any owner can be blocked or removed later.
The tile opens a status page behind your umbrelOS login, showing whether the relay is
running, how much it holds, when it was last written to, and which owners are allowed. Point
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: >-
Tidying up the two owner panels. They are called Accepted and Waiting now, the count of owners waiting
sits in that panel's own heading instead of a widget at the top, the note under the relay address is
gone, and a row on the waiting list no longer carries a "Waiting" tag: the panel it sits in already says
that, and a blocked owner is now the only row with a tag.
Every row is the same height as well, in both panels. A row used to grow the moment you gave it a
name, because the name pushed the owner id onto a line of its own; the id now shares the second line
with the timestamps.
About this app no longer insists the sync address must start with http. Which scheme the field in your
app wants depends on the app; the host and the port are the same either way.
Everything in 0.6.0:
The whole app now ships as one image: the relay, the status page and the small helper behind it. Until
now only the relay did, and the other two were mounted into stock images from the app folder. Nothing
changes in what the app does; the app data folder now holds only your data, and the icon updates along
with everything else.
Everything in 0.5.6:
Owners are now listed most recently seen first, in both panels. Until now the rows kept the order in
which owners first showed up, which stops meaning anything after a week. The order follows along on its
own: the page refreshes its lists every few seconds, so a reconnecting owner moves to the top without a
reload.
Everything in 0.5.4:
The status widget has moved to the right of the top row and is now called Status, matching Electrum
Gate. The two pages are one app store and they should read the same way.
Everything in 0.5.3:
Two adjustments to the refresh. The Relay card reads in the accent colour again when it says
Running, and on a phone the page drops back to the flat look: the glow is wider than a phone screen,
so it stopped being a gradient and became a haze over everything.
Everything in 0.5.2:
A visual refresh, shared with the other apps in this store. The page now has a soft glow behind it in
the accent colour, and the cards are translucent with a light edge and a shadow, so they read as
panels sitting on the page instead of flat blocks painted on it. Card corners are a little tighter,
and every framed element now uses the same corner radius. Nothing about what the relay does has
changed.
Everything in 0.5.1:
Fixes a message at the top of the page that never went away. Confirming that a command was sent is worth
saying for a second or two, but it stayed until you reloaded the page, so it was still there long after it
stopped being true. It now clears itself as soon as the relay has picked the command up. A refusal is the
exception and stays put: that is the only explanation you get of what went wrong.
Everything in 0.5.0, released the same day:
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.
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
that relay refuses every owner the quota manager never registered. 0.2.0 uses the relay from the
Evolu project instead. Measured, not assumed: a client synced to it and the data arrived. Three
containers became one relay plus a status page, and the database and its password are gone.
0.2.0 also added the status page behind your umbrelOS login, and control over who may sync.
Not verified yet: reading back from a second device, and whether every client platform can use a
relay of your own.
developer: "WhatsNext?"
website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
repo: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
support: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/issues
# De poort waarop umbrelOS deze app aanbiedt. Sinds 0.1.0 is dat de STATUSPAGINA
# en niet de relay: de app-proxy zet er de inlog van umbrelOS voor, en daar hoort
# een pagina wel achter en een relay niet. De cliënt wijst naar 3852, die de
# relay zelf publiceert. Zie docker-compose.yml en het plan Umbrelapp, PLAN.md §4h.
#
# Niet 4000, de eigen poort van de relay: die is een veelgebruikte poort en een
# botsing op de host merk je pas als de app niet start. Dat heeft bij Electrum
# Gate een keer een dag gekost, met 50002 tegen Fulcrum. 3851 sluit aan op de 3850
# van die app.
port: 3851
# Leeg laten bij een nieuw pakket; de plaatjes doet het store-team zelf.
gallery: []
path: ""
submitter: "WhatsNext?"
submission: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
# ── Hieronder staat wat de voorgeschreven volgorde niet noemt ────────────────
#
# Het icoon, sinds 28-08-2026. Het bestand staat in deze repo en wordt door Gitea
# rauw uitgeleverd; dezelfde constructie als bij Electrum Gate. Bij inlevering in
# de officiële store moet deze regel juist weg, want die host iconen apart.
# Daarom staat hij als laatste: dan is het één regel schrappen.
icon: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/raw/branch/main/whatsnext-evolu-relay/icon.png
# Wat niet in de back-up hoeft. Paden zijn relatief aan de app-datamap.
#
# Voor deze app is dat vrijwel niets, en dat is expres: de database ís de waarde
# van de app. Electrum Gate gebruikt dit veld wel, maar daar gaat het om een log
# en een statusbestand. Neem die keuze hier niet uit gewoonte over.
backupIgnore: []