commit 85d85a1295dd0684b024c38778ef594b1f7d7b0f Author: Harmen Date: Fri Oct 9 10:40:25 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..41f254b --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4dcd936 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de36d5c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae607dc --- /dev/null +++ b/README.md @@ -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. diff --git a/tests/test_agent_certificates.py b/tests/test_agent_certificates.py new file mode 100644 index 0000000..268e2b5 --- /dev/null +++ b/tests/test_agent_certificates.py @@ -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()) diff --git a/tests/test_agent_zelfcontrole.py b/tests/test_agent_zelfcontrole.py new file mode 100644 index 0000000..2257fd5 --- /dev/null +++ b/tests/test_agent_zelfcontrole.py @@ -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()) diff --git a/tests/test_appstore_vorm.py b/tests/test_appstore_vorm.py new file mode 100644 index 0000000..ccbdea3 --- /dev/null +++ b/tests/test_appstore_vorm.py @@ -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//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//. + + 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 + `__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 __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 __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//` 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__. 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()) diff --git a/tests/test_client_lokaal.mjs b/tests/test_client_lokaal.mjs new file mode 100644 index 0000000..5e29901 --- /dev/null +++ b/tests/test_client_lokaal.mjs @@ -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); diff --git a/tests/test_client_register.mjs b/tests/test_client_register.mjs new file mode 100644 index 0000000..3cdce3b --- /dev/null +++ b/tests/test_client_register.mjs @@ -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); diff --git a/tests/test_limiter.mjs b/tests/test_limiter.mjs new file mode 100644 index 0000000..f36301a --- /dev/null +++ b/tests/test_limiter.mjs @@ -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); diff --git a/tests/test_paginas_parsen.mjs b/tests/test_paginas_parsen.mjs new file mode 100644 index 0000000..4e9b43c --- /dev/null +++ b/tests/test_paginas_parsen.mjs @@ -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 + + diff --git a/tools/electrum-gate/nginx.conf b/tools/electrum-gate/nginx.conf new file mode 100644 index 0000000..b4d9759 --- /dev/null +++ b/tools/electrum-gate/nginx.conf @@ -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 __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; diff --git a/tools/electrum-gate/stream.conf b/tools/electrum-gate/stream.conf new file mode 100644 index 0000000..030fc58 --- /dev/null +++ b/tools/electrum-gate/stream.conf @@ -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__; + } +} diff --git a/tools/evolu-relay/Dockerfile b/tools/evolu-relay/Dockerfile new file mode 100644 index 0000000..8eeffa4 --- /dev/null +++ b/tools/evolu-relay/Dockerfile @@ -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"] diff --git a/tools/evolu-relay/agent.py b/tools/evolu-relay/agent.py new file mode 100644 index 0000000..83c7633 --- /dev/null +++ b/tools/evolu-relay/agent.py @@ -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() diff --git a/tools/evolu-relay/build.sh b/tools/evolu-relay/build.sh new file mode 100644 index 0000000..e40bee8 --- /dev/null +++ b/tools/evolu-relay/build.sh @@ -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}" diff --git a/tools/evolu-relay/index.html b/tools/evolu-relay/index.html new file mode 100644 index 0000000..5407d30 --- /dev/null +++ b/tools/evolu-relay/index.html @@ -0,0 +1,1429 @@ + + + + + + + +Evolu Relay + + + + +
+ +
+ +
+

Evolu Relay

+ +

Encrypted sync and backup for your local-first apps

+
+ + +
+ + + + + + + + +
+ + +
+
+

Stored data

+
+
unknown
+
+
+
+ +
+

Last write

+
+
unknown
+
+
+
+ + +
+ +
+

Status

+
+
unknown
+
+
+
+
+ + +
+

Accepted

+
+
+ + +
+ +
+

Waiting

+
+
+
+ + + +
+

Relay address

+ +
unknown
+
+
+ + + +
+

About this app

+ + +
+ +
+

What it is

+

+ 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. +

+ +

What the relay can see

+

+ 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. +

+ +

Pointing an app at it

+ +

+ Set the address at the bottom of this page as the sync address in your app. Some apps only + offer the field in their developer or advanced settings. Which scheme that field wants + depends on the app: ws:// as shown below, or http://, + which some apps expect because the websocket starts as an HTTP request and they work the + rest out themselves. Same host and same port either way, so try the other one if the first + is refused. +

+

+ Away from home you will need a way in, such as Tailscale or a reverse proxy with your own + domain. Behind a proxy with a certificate the address becomes wss:// (or + https) on your own domain, without a port number: the proxy listens on 443 and forwards to + this one. +

+ +

Who may use it

+

+ What this app adds is the part a self-hosted relay needs and a public one does not: control + over who may use it. Nobody gets in by default. An owner that connects for the first time is + turned away and lands on the waiting list with its owner id, and you decide + there whether it may stay. One app can use more than one owner id, so expect more than one + row per device. +

+

+ An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay + turns the connection away before it starts. Its device keeps everything locally and retries, + so allowing it later still brings the whole history across, and you do not have to be quick. +

+

+ Owner ids are long and meaningless on purpose. Hover a row and use Label to + give one a name of your own; the name is kept on the Umbrel and is only ever shown here. +

+
+
+
+ + + + diff --git a/tools/evolu-relay/nginx.conf b/tools/evolu-relay/nginx.conf new file mode 100644 index 0000000..97c31b9 --- /dev/null +++ b/tools/evolu-relay/nginx.conf @@ -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 + # __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; + } + } +} diff --git a/tools/evolu-relay/package.json b/tools/evolu-relay/package.json new file mode 100644 index 0000000..0e1a25b --- /dev/null +++ b/tools/evolu-relay/package.json @@ -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" + } +} diff --git a/tools/evolu-relay/src/index.js b/tools/evolu-relay/src/index.js new file mode 100644 index 0000000..da63a61 --- /dev/null +++ b/tools/evolu-relay/src/index.js @@ -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; + }, + }), +); diff --git a/tools/evolu-relay/src/policy.js b/tools/evolu-relay/src/policy.js new file mode 100644 index 0000000..02d3f3e --- /dev/null +++ b/tools/evolu-relay/src/policy.js @@ -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'); + } +}; diff --git a/tools/evolu-relay/src/store.js b/tools/evolu-relay/src/store.js new file mode 100644 index 0000000..3b81554 --- /dev/null +++ b/tools/evolu-relay/src/store.js @@ -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); diff --git a/tools/gitea-runner/.env.sample b/tools/gitea-runner/.env.sample new file mode 100644 index 0000000..d6edf51 --- /dev/null +++ b/tools/gitea-runner/.env.sample @@ -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= diff --git a/tools/gitea-runner/docker-compose.yml b/tools/gitea-runner/docker-compose.yml new file mode 100644 index 0000000..b2086a4 --- /dev/null +++ b/tools/gitea-runner/docker-compose.yml @@ -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 diff --git a/tools/icons/README.md b/tools/icons/README.md new file mode 100644 index 0000000..7746f82 --- /dev/null +++ b/tools/icons/README.md @@ -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//`. 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. diff --git a/tools/relay-client/.env.sample b/tools/relay-client/.env.sample new file mode 100644 index 0000000..f487d4f --- /dev/null +++ b/tools/relay-client/.env.sample @@ -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 diff --git a/tools/relay-client/Start.bat b/tools/relay-client/Start.bat new file mode 100644 index 0000000..7a87607 --- /dev/null +++ b/tools/relay-client/Start.bat @@ -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 diff --git a/tools/relay-client/Start.command b/tools/relay-client/Start.command new file mode 100755 index 0000000..d96f426 --- /dev/null +++ b/tools/relay-client/Start.command @@ -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." diff --git a/tools/relay-client/package-lock.json b/tools/relay-client/package-lock.json new file mode 100644 index 0000000..a428d28 --- /dev/null +++ b/tools/relay-client/package-lock.json @@ -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 + } + } + } + } +} diff --git a/tools/relay-client/package.json b/tools/relay-client/package.json new file mode 100644 index 0000000..bca608b --- /dev/null +++ b/tools/relay-client/package.json @@ -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" + } +} diff --git a/tools/relay-client/src/argumenten.js b/tools/relay-client/src/argumenten.js new file mode 100644 index 0000000..a038feb --- /dev/null +++ b/tools/relay-client/src/argumenten.js @@ -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 [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 Omvang van de blob bij `schrijf` (standaard 64)'); + regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`'); + regels.push(' --wacht Seconden wachten op de relay, bij `spiegel` (standaard 15)'); + regels.push(' --poort 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'); +}; diff --git a/tools/relay-client/src/cli.js b/tools/relay-client/src/cli.js new file mode 100644 index 0000000..3d21291 --- /dev/null +++ b/tools/relay-client/src/cli.js @@ -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 ` 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); +} diff --git a/tools/relay-client/src/client.js b/tools/relay-client/src/client.js new file mode 100644 index 0000000..595b3fb --- /dev/null +++ b/tools/relay-client/src/client.js @@ -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(); + }, + }; +}; diff --git a/tools/relay-client/src/config.js b/tools/relay-client/src/config.js new file mode 100644 index 0000000..a5132c6 --- /dev/null +++ b/tools/relay-client/src/config.js @@ -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), + }; +}; diff --git a/tools/relay-client/src/evolu-node.js b/tools/relay-client/src/evolu-node.js new file mode 100644 index 0000000..35d7801 --- /dev/null +++ b/tools/relay-client/src/evolu-node.js @@ -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](); + }, + }; +}; diff --git a/tools/relay-client/src/index.html b/tools/relay-client/src/index.html new file mode 100644 index 0000000..3178d81 --- /dev/null +++ b/tools/relay-client/src/index.html @@ -0,0 +1,324 @@ + + + + + + + Relay-testclient + + + +
+

Relay-testclient

+
+ Relay: … + +
+ +

Nieuwe eigenaar

+
+
+ + + +
+
+ +

Eigenaars

+
+ +

Log

+
+
+ + + + diff --git a/tools/relay-client/src/probe.js b/tools/relay-client/src/probe.js new file mode 100644 index 0000000..a535730 --- /dev/null +++ b/tools/relay-client/src/probe.js @@ -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(); + }); diff --git a/tools/relay-client/src/register.js b/tools/relay-client/src/register.js new file mode 100644 index 0000000..78cd475 --- /dev/null +++ b/tools/relay-client/src/register.js @@ -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 }; + }, + }; +}; diff --git a/tools/relay-client/src/ui.js b/tools/relay-client/src/ui.js new file mode 100644 index 0000000..d858d41 --- /dev/null +++ b/tools/relay-client/src/ui.js @@ -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(() => {}); +}; diff --git a/tools/release-bump.mjs b/tools/release-bump.mjs new file mode 100644 index 0000000..f1a1385 --- /dev/null +++ b/tools/release-bump.mjs @@ -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 +// 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 [--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; + }); +} diff --git a/tools/voorbeeldpagina.mjs b/tools/voorbeeldpagina.mjs new file mode 100644 index 0000000..b76445a --- /dev/null +++ b/tools/voorbeeldpagina.mjs @@ -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/.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 = [ + '', + ].join('\n'); + + const anker = '