UmbrelApps: community app store whatsnext
Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
85d85a1295
63 files changed
+13987
No files matched your search
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# .gitattributes - UmbrelApps
|
||||||
|
#
|
||||||
|
# Waarom dit bestand er is, en meteen bij het opzetten en niet later: op deze
|
||||||
|
# Windows-machine staat core.autocrlf vaak op true. Zonder deze regels waarschuwt
|
||||||
|
# git bij elke `git add` dat "LF will be replaced by CRLF", en komt er CRLF/LF-ruis
|
||||||
|
# in diffs zodra er afwisselend met een editor en met CLI-tooling gewerkt wordt.
|
||||||
|
#
|
||||||
|
# Voor dit project is er een hardere reden dan diff-ruis. De shellscripts en de
|
||||||
|
# YAML worden op een Umbrel in een Linux-container uitgevoerd. Een CRLF achter
|
||||||
|
# `#!/bin/sh` laat het script stuklopen op een foutmelding die nergens naar het
|
||||||
|
# echte probleem wijst, en een CRLF in docker-compose.yml geeft waarden met een
|
||||||
|
# onzichtbare wagenterugloop erin. Dit bestand voorkomt allebei.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Standaard: git detecteert tekst vs. binair, slaat tekst op als LF en checkt ook
|
||||||
|
# LF uit. Dat houdt de werkmap identiek aan wat er in de repo staat.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# ── Alles wat op de Umbrel draait: LF, zonder uitzondering ────────────────────
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
|
*.sql text eol=lf
|
||||||
|
*.template text eol=lf
|
||||||
|
|
||||||
|
# ── Broncode en documentatie expliciet als tekst ──────────────────────────────
|
||||||
|
# Strikt genomen dekt `text=auto` dit al. Toch opschrijven: het maakt zichtbaar
|
||||||
|
# waar dit project uit bestaat, en het haalt de gok uit de detectie voor een
|
||||||
|
# bestand dat toevallig op een binair patroon lijkt.
|
||||||
|
*.md text
|
||||||
|
*.txt text
|
||||||
|
*.html text
|
||||||
|
*.css text
|
||||||
|
*.js text
|
||||||
|
*.json text
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
|
||||||
|
# ── Binair: nooit converteren, geen tekst-diff ────────────────────────────────
|
||||||
|
# Het icoon en de gallery-afbeeldingen komen hier bij het plan Appstore.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.svg text
|
||||||
|
*.pem binary
|
||||||
|
*.key binary
|
||||||
+93
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
"""Toetst de vorm die umbrelOS van deze app store eist, voor élke app erin.
|
||||||
|
|
||||||
|
Waarom deze test bestaat. De twee bestaande testbestanden gaan over Electrum
|
||||||
|
Gate; ze noemen die app-map bij naam. Sinds 25-08-2026 zit er een tweede app in
|
||||||
|
deze repo en zijn er dus regels die niet over één app gaan maar over alle:
|
||||||
|
|
||||||
|
- het `id` in `umbrel-app.yml` moet gelijk zijn aan de mapnaam;
|
||||||
|
- dat id moet beginnen met het store-id uit `umbrel-app-store.yml`;
|
||||||
|
- de manifestvelden staan in de volgorde die de packaging-documentatie
|
||||||
|
voorschrijft;
|
||||||
|
- er is een `docker-compose.yml`, en de `app_proxy` daarin wijst naar een
|
||||||
|
service die in datzelfde bestand bestaat.
|
||||||
|
|
||||||
|
Dat zijn precies de fouten die je op het apparaat pas merkt: de app verschijnt
|
||||||
|
niet in de store, of hij verschijnt en start niet. Er komt geen nette
|
||||||
|
foutmelding, want de runtime-validatie van het manifest staat in umbreld
|
||||||
|
uitgecommentarieerd.
|
||||||
|
|
||||||
|
Wat deze toetsen expres NIET doen: eisen dat elke image op een digest gepind is.
|
||||||
|
Dat is wél de regel, maar op 25-08-2026 haalt geen van de twee apps hem, en een
|
||||||
|
suite die altijd rood staat wordt niet gelezen. Het staat als taak in de plannen
|
||||||
|
Umbrelapp en Publicatie-Gate. Wat hier wel gebeurt is de pinstatus afdrukken, zodat
|
||||||
|
je hem ziet zonder erover te struikelen. Wat wél getoetst wordt, sinds 07-09-2026:
|
||||||
|
dat de tag van een eigen image gelijk is aan VERSION in het bijbehorende
|
||||||
|
tools/<app>/build.sh, want een tag die nooit gebouwd is faalt pas op het apparaat.
|
||||||
|
|
||||||
|
Deze toetsen vinden hun apps zelf. Komt er een derde app bij, dan valt die
|
||||||
|
automatisch onder alles hierboven en hoeft hier niets bij.
|
||||||
|
|
||||||
|
Draaien:
|
||||||
|
|
||||||
|
python tests/test_appstore_vorm.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
|
||||||
|
import os # noqa: E402
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
|
||||||
|
STORE = os.path.join(REPO, "umbrel-app-store.yml")
|
||||||
|
|
||||||
|
# De volgorde uit de packaging-documentatie. Wat de spec niet noemt (icon,
|
||||||
|
# backupIgnore, submitter, submission) mag erachter, niet ertussen.
|
||||||
|
VOORGESCHREVEN = [
|
||||||
|
"manifestVersion", "id", "category", "name", "version", "tagline",
|
||||||
|
"description", "releaseNotes", "developer", "website", "dependencies",
|
||||||
|
"repo", "support", "port", "gallery", "path",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Velden die er per se moeten staan, ook al is het schema soepeler: zonder deze
|
||||||
|
# is de winkelpagina leeg of start de app niet.
|
||||||
|
VERPLICHT = ["manifestVersion", "id", "category", "name", "version", "tagline",
|
||||||
|
"description", "port"]
|
||||||
|
|
||||||
|
|
||||||
|
class Uitslag:
|
||||||
|
def __init__(self):
|
||||||
|
self.goed = 0
|
||||||
|
self.fout = []
|
||||||
|
|
||||||
|
def check(self, naam, gelukt, uitleg=""):
|
||||||
|
if gelukt:
|
||||||
|
self.goed += 1
|
||||||
|
else:
|
||||||
|
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
|
||||||
|
|
||||||
|
def rapport(self):
|
||||||
|
print()
|
||||||
|
print("%d goed, %d fout" % (self.goed, len(self.fout)))
|
||||||
|
for f in self.fout:
|
||||||
|
print(" FOUT: " + f)
|
||||||
|
return 0 if not self.fout else 1
|
||||||
|
|
||||||
|
|
||||||
|
def lees(pad):
|
||||||
|
with open(pad, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def veld(tekst, naam):
|
||||||
|
"""De waarde van een veld op het eerste niveau, of None.
|
||||||
|
|
||||||
|
Geen YAML-lezer: de suite heeft geen afhankelijkheden en dat is een
|
||||||
|
projectregel. Daarom alleen velden die aan het begin van een regel staan.
|
||||||
|
"""
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
if regel.startswith(naam + ":"):
|
||||||
|
return regel.split(":", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def velden_in_volgorde(tekst):
|
||||||
|
"""De veldnamen op het eerste niveau, in de volgorde waarin ze staan."""
|
||||||
|
namen = []
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
if not regel or regel[0] in " \t#-":
|
||||||
|
continue
|
||||||
|
if ":" not in regel:
|
||||||
|
continue
|
||||||
|
namen.append(regel.split(":", 1)[0].strip())
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
|
def app_mappen():
|
||||||
|
"""Elke map in de repo-root met een umbrel-app.yml erin."""
|
||||||
|
gevonden = []
|
||||||
|
for naam in sorted(os.listdir(REPO)):
|
||||||
|
pad = os.path.join(REPO, naam)
|
||||||
|
if not os.path.isdir(pad):
|
||||||
|
continue
|
||||||
|
if os.path.isfile(os.path.join(pad, "umbrel-app.yml")):
|
||||||
|
gevonden.append(naam)
|
||||||
|
return gevonden
|
||||||
|
|
||||||
|
|
||||||
|
def test_er_is_een_store_met_apps(u, store_id, apps):
|
||||||
|
u.check("umbrel-app-store.yml heeft een id", bool(store_id))
|
||||||
|
u.check("en er is minstens een app-map gevonden", bool(apps),
|
||||||
|
"geen enkele map met een umbrel-app.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_en_mapnaam(u, store_id, app):
|
||||||
|
"""Mapnaam == id == store-prefix + rest. Alle drie of de app bestaat niet."""
|
||||||
|
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
|
||||||
|
app_id = veld(tekst, "id")
|
||||||
|
|
||||||
|
u.check("%s: het manifest heeft een id" % app, bool(app_id))
|
||||||
|
if not app_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
u.check("%s: id is gelijk aan de mapnaam" % app, app_id == app,
|
||||||
|
"manifest zegt %r" % app_id)
|
||||||
|
u.check("%s: id begint met het store-id %r" % (app, store_id),
|
||||||
|
app_id.startswith(store_id + "-"),
|
||||||
|
"id is %r" % app_id)
|
||||||
|
u.check("%s: id is lowercase kebab-case" % app,
|
||||||
|
app_id == app_id.lower() and " " not in app_id and "_" not in app_id,
|
||||||
|
"id is %r" % app_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verplichte_velden(u, app):
|
||||||
|
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
|
||||||
|
ontbreekt = [naam for naam in VERPLICHT if veld(tekst, naam) is None]
|
||||||
|
u.check("%s: alle verplichte manifestvelden staan erin" % app,
|
||||||
|
not ontbreekt, "ontbreekt: %r" % ontbreekt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_volgorde(u, app):
|
||||||
|
"""De voorgeschreven velden in de voorgeschreven onderlinge orde.
|
||||||
|
|
||||||
|
Niet dat ze er alle zestien zijn: `dependencies` hoort weg te blijven bij een
|
||||||
|
app zonder afhankelijkheden. Wel dat wat er staat niet door elkaar loopt, want
|
||||||
|
dat is de eis waar een review op valt.
|
||||||
|
"""
|
||||||
|
aanwezig = velden_in_volgorde(lees(os.path.join(REPO, app, "umbrel-app.yml")))
|
||||||
|
volgens_spec = [n for n in aanwezig if n in VOORGESCHREVEN]
|
||||||
|
verwacht = [n for n in VOORGESCHREVEN if n in volgens_spec]
|
||||||
|
|
||||||
|
u.check("%s: de manifestvelden staan in de voorgeschreven volgorde" % app,
|
||||||
|
volgens_spec == verwacht,
|
||||||
|
"gevonden %r, verwacht %r" % (volgens_spec, verwacht))
|
||||||
|
|
||||||
|
# Wat de spec niet noemt hoort erachter, niet ertussen. Anders is de kop niet
|
||||||
|
# letterlijk goed en moet er bij inlevering geschoven worden.
|
||||||
|
laatste_spec = -1
|
||||||
|
for i, naam in enumerate(aanwezig):
|
||||||
|
if naam in VOORGESCHREVEN:
|
||||||
|
laatste_spec = i
|
||||||
|
extra_ertussen = [n for n in aanwezig[:laatste_spec] if n not in VOORGESCHREVEN]
|
||||||
|
u.check("%s: velden buiten de spec staan achteraan" % app,
|
||||||
|
not extra_ertussen, "ertussen: %r" % extra_ertussen)
|
||||||
|
|
||||||
|
|
||||||
|
def recept_map(app):
|
||||||
|
"""Waar het bouwrecept van een app staat: tools/<app-id zonder store-id>/.
|
||||||
|
|
||||||
|
Dat is de afspraak uit Werkafspraken.md: een Dockerfile hoort niet in de app-map,
|
||||||
|
want die staat niet in de update-whitelist. De mapnaam is het app-id zonder
|
||||||
|
het store-voorvoegsel, dus whatsnext-evolu-relay bouwt in tools/evolu-relay/.
|
||||||
|
"""
|
||||||
|
kort = app.split("-", 1)[1] if "-" in app else app
|
||||||
|
return os.path.join(REPO, "tools", kort)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_tag_is_de_gebouwde(u, app):
|
||||||
|
"""De tag in de compose is VERSION uit build.sh, voor elke eigen image.
|
||||||
|
|
||||||
|
Een tag die in de compose staat maar nooit gebouwd is, faalt pas op het
|
||||||
|
apparaat met "pull access denied". En andersom, een VERSION die verhoogd is
|
||||||
|
zonder de compose mee te nemen, levert een nieuwe image en een oude
|
||||||
|
installatie zonder dat iets dat meldt. Beide staan als regel in Werkafspraken.md;
|
||||||
|
dit is de toets erop.
|
||||||
|
|
||||||
|
Alleen images uit het eigen recept: een vreemde image (postgres, nginx) heeft
|
||||||
|
geen build.sh en valt hier buiten.
|
||||||
|
"""
|
||||||
|
build = os.path.join(recept_map(app), "build.sh")
|
||||||
|
if not os.path.isfile(build):
|
||||||
|
return
|
||||||
|
|
||||||
|
versie = None
|
||||||
|
for regel in lees(build).splitlines():
|
||||||
|
if regel.startswith("VERSION="):
|
||||||
|
versie = regel.split("=", 1)[1].strip().strip('"')
|
||||||
|
u.check("%s: build.sh heeft een VERSION" % app, bool(versie))
|
||||||
|
if not versie:
|
||||||
|
return
|
||||||
|
|
||||||
|
kort = os.path.basename(recept_map(app))
|
||||||
|
compose = lees(os.path.join(REPO, app, "docker-compose.yml"))
|
||||||
|
eigen = []
|
||||||
|
for regel in compose.splitlines():
|
||||||
|
kaal = regel.strip()
|
||||||
|
if not kaal.startswith("image:"):
|
||||||
|
continue
|
||||||
|
image = kaal.split(":", 1)[1].strip()
|
||||||
|
repo_en_tag = image.split("@", 1)[0]
|
||||||
|
repo_naam = repo_en_tag.rsplit(":", 1)[0]
|
||||||
|
if repo_naam.endswith("/" + kort):
|
||||||
|
eigen.append(repo_en_tag)
|
||||||
|
|
||||||
|
u.check("%s: de compose gebruikt de eigen image" % app, bool(eigen),
|
||||||
|
"geen image op .../%s gevonden" % kort)
|
||||||
|
verkeerd = [i for i in eigen if not i.endswith(":" + versie)]
|
||||||
|
u.check("%s: elke eigen image heeft tag %s uit build.sh" % (app, versie),
|
||||||
|
not verkeerd, "gevonden: %r" % verkeerd)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_bestaat_en_hangt_samen(u, app):
|
||||||
|
"""De app_proxy moet naar een service wijzen die bestaat.
|
||||||
|
|
||||||
|
Dit is een echte klasse fouten en niet een formaliteit: de hostnaam is
|
||||||
|
`<app-id>_<service>_1`, dus hij bevat het app-id én de servicenaam. Verandert
|
||||||
|
een van de twee, dan wijst de proxy naar niets en meldt umbrelOS alleen dat de
|
||||||
|
server onbereikbaar is. Precies het symptoom dat bij Electrum Gate 0.0.3 een
|
||||||
|
dag kostte.
|
||||||
|
"""
|
||||||
|
pad = os.path.join(REPO, app, "docker-compose.yml")
|
||||||
|
u.check("%s: er is een docker-compose.yml" % app, os.path.isfile(pad))
|
||||||
|
if not os.path.isfile(pad):
|
||||||
|
return
|
||||||
|
|
||||||
|
tekst = lees(pad)
|
||||||
|
|
||||||
|
# Servicenamen: twee spaties diep onder services:, eindigend op een dubbele
|
||||||
|
# punt. Geen YAML-lezer, zie veld().
|
||||||
|
services = []
|
||||||
|
in_services = False
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
if regel.startswith("services:"):
|
||||||
|
in_services = True
|
||||||
|
continue
|
||||||
|
if in_services and regel and not regel[0].isspace():
|
||||||
|
break
|
||||||
|
if in_services and regel.startswith(" ") and not regel.startswith(" "):
|
||||||
|
kaal = regel.strip()
|
||||||
|
if kaal.endswith(":") and not kaal.startswith("#"):
|
||||||
|
services.append(kaal[:-1])
|
||||||
|
|
||||||
|
u.check("%s: de compose heeft een app_proxy" % app,
|
||||||
|
"app_proxy" in services, "services: %r" % services)
|
||||||
|
|
||||||
|
app_host = None
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
if regel.strip().startswith("APP_HOST:"):
|
||||||
|
app_host = regel.split(":", 1)[1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
u.check("%s: de app_proxy heeft een APP_HOST" % app, bool(app_host))
|
||||||
|
if not app_host:
|
||||||
|
return
|
||||||
|
|
||||||
|
u.check("%s: APP_HOST begint met het app-id" % app,
|
||||||
|
app_host.startswith(app + "_"),
|
||||||
|
"APP_HOST is %r maar de map heet %r" % (app_host, app))
|
||||||
|
|
||||||
|
# De vorm is <app-id>_<service>_1; haal de servicenaam eruit en kijk of die
|
||||||
|
# bestaat.
|
||||||
|
rest = app_host[len(app) + 1:]
|
||||||
|
u.check("%s: APP_HOST eindigt op _1" % app, rest.endswith("_1"),
|
||||||
|
"APP_HOST is %r" % app_host)
|
||||||
|
service = rest[:-2] if rest.endswith("_1") else rest
|
||||||
|
u.check("%s: APP_HOST wijst naar een service die bestaat" % app,
|
||||||
|
service in services,
|
||||||
|
"wijst naar %r, aanwezig: %r" % (service, services))
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_onder_data(u, app):
|
||||||
|
"""Wat de app bewaart staat onder data/, en er is een .gitkeep per map.
|
||||||
|
|
||||||
|
De appstore-eis is dat gebruikersstaat onder ${APP_DATA_DIR}/data/... valt en
|
||||||
|
dat elke map die bij de eerste start moet bestaan in de repo staat. Een mount
|
||||||
|
naar een map die er niet is, maakt Docker aan als root, en dan kan de app er
|
||||||
|
niet in schrijven.
|
||||||
|
"""
|
||||||
|
pad = os.path.join(REPO, app, "docker-compose.yml")
|
||||||
|
if not os.path.isfile(pad):
|
||||||
|
return
|
||||||
|
tekst = lees(pad)
|
||||||
|
|
||||||
|
buiten = []
|
||||||
|
ontbreekt = []
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
kaal = regel.strip()
|
||||||
|
if not kaal.startswith("- ${APP_DATA_DIR}/"):
|
||||||
|
continue
|
||||||
|
pad_in_app = kaal[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]
|
||||||
|
# Losse bestanden die uit een *.template komen zijn geen gebruikersstaat.
|
||||||
|
if "/" not in pad_in_app:
|
||||||
|
continue
|
||||||
|
if not pad_in_app.startswith("data/"):
|
||||||
|
buiten.append(pad_in_app)
|
||||||
|
continue
|
||||||
|
op_schijf = os.path.join(REPO, app, pad_in_app)
|
||||||
|
if not os.path.exists(op_schijf):
|
||||||
|
ontbreekt.append(pad_in_app)
|
||||||
|
elif os.path.isdir(op_schijf) and not os.path.isfile(
|
||||||
|
os.path.join(op_schijf, ".gitkeep")):
|
||||||
|
ontbreekt.append(pad_in_app + "/.gitkeep")
|
||||||
|
|
||||||
|
u.check("%s: alle mounts met een pad staan onder data/" % app,
|
||||||
|
not buiten, "erbuiten: %r" % buiten)
|
||||||
|
u.check("%s: elke gemounte map bestaat in de repo" % app,
|
||||||
|
not ontbreekt, "ontbreekt: %r" % ontbreekt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_containernamen_zijn_volledig(u, app):
|
||||||
|
"""Verwijzingen tussen containers gebruiken de volledige naam.
|
||||||
|
|
||||||
|
Alle apps van umbrelOS delen één Docker-netwerk, dus een servicenaam als
|
||||||
|
'agent' of 'server' is daar niet uniek. Heeft een tweede app dezelfde naam,
|
||||||
|
dan verdeelt Docker de naam over beide containers en komt ongeveer de helft
|
||||||
|
van het verkeer bij de verkeerde app uit.
|
||||||
|
|
||||||
|
Dat is geen theorie: op 28-08-2026 gaf de statuspagina van Evolu Relay
|
||||||
|
afwisselend goede gegevens en een 404, omdat Electrum Gate óók een 'agent' op
|
||||||
|
poort 8000 heeft. Van tien verzoeken kwamen er vijf verkeerd uit. Het maakte
|
||||||
|
bovendien de pagina van Electrum Gate kapot, een app die tot dat moment werkte.
|
||||||
|
|
||||||
|
De vorm <app-id>_<service>_1 is wél uniek. Deze toets kijkt naar de plekken
|
||||||
|
waar een naam als hostnaam wordt gebruikt: proxy_pass in een nginx-config, en
|
||||||
|
de omgevingsvariabelen die een hostnaam dragen. Een nginx-config kan in de
|
||||||
|
app-map staan (als *.template) of in het bouwrecept onder tools/ (als hij in
|
||||||
|
een eigen image zit); beide worden gelezen.
|
||||||
|
"""
|
||||||
|
fout = []
|
||||||
|
|
||||||
|
bestanden = []
|
||||||
|
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
|
||||||
|
if bestandsnaam.endswith(".template") or bestandsnaam == "docker-compose.yml":
|
||||||
|
bestanden.append(os.path.join(REPO, app, bestandsnaam))
|
||||||
|
recept = recept_map(app)
|
||||||
|
if os.path.isdir(recept):
|
||||||
|
for bestandsnaam in sorted(os.listdir(recept)):
|
||||||
|
if bestandsnaam.endswith(".conf"):
|
||||||
|
bestanden.append(os.path.join(recept, bestandsnaam))
|
||||||
|
|
||||||
|
for pad in bestanden:
|
||||||
|
bestandsnaam = os.path.relpath(pad, REPO).replace(os.sep, "/")
|
||||||
|
tekst = lees(pad)
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
kaal = regel.strip()
|
||||||
|
if kaal.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
naam = None
|
||||||
|
if kaal.startswith("proxy_pass http://"):
|
||||||
|
naam = kaal[len("proxy_pass http://"):].split(":", 1)[0].split("/", 1)[0]
|
||||||
|
elif "_HOST:" in kaal and not kaal.startswith("- "):
|
||||||
|
waarde = kaal.split(":", 1)[1].strip().strip('"')
|
||||||
|
# Een variabele van umbrelOS of een IP-adres is geen containernaam.
|
||||||
|
if waarde and not waarde.startswith("${") and not waarde[0].isdigit():
|
||||||
|
naam = waarde
|
||||||
|
|
||||||
|
if naam is None or naam in ("localhost", "127.0.0.1"):
|
||||||
|
continue
|
||||||
|
if not naam.startswith(app + "_"):
|
||||||
|
fout.append("%s: %s" % (bestandsnaam, naam))
|
||||||
|
|
||||||
|
u.check("%s: verwijzingen naar containers gebruiken de volledige naam" % app,
|
||||||
|
not fout, "korte namen: %r" % fout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_geen_werkbestanden_in_de_app_map(u, app):
|
||||||
|
"""In een app-map staat alleen wat umbreld nodig heeft.
|
||||||
|
|
||||||
|
umbreld kopieert bij een installatie de héle app-map naar
|
||||||
|
`~/umbrel/app-data/<app-id>/` met `rsync --archive`. Alles wat daar staat
|
||||||
|
belandt dus op het apparaat en in de back-up: een CLAUDE.md, een gelaagd
|
||||||
|
bewerkbestand van een icoon, een testscript, een aantekening.
|
||||||
|
|
||||||
|
Dat is geen theorie. Op 30-08-2026 stond er een `icon.pdn` van 175 kB in de map
|
||||||
|
van Evolu Relay, een Paint.NET-bestand van een icoon dat nog niet af was. Het
|
||||||
|
apparaat kan er niets mee en het maakt elke back-up groter. Verplaatst naar
|
||||||
|
`tools/icons/`; deze toets is wat voorkomt dat de volgende terugkomt.
|
||||||
|
|
||||||
|
De lijst hieronder is een whitelist en geen blacklist, en dat is met opzet: bij
|
||||||
|
een nieuw soort bestand hoort iemand na te denken of het daar hoort, en een
|
||||||
|
blacklist stelt die vraag nooit.
|
||||||
|
"""
|
||||||
|
TOEGESTAAN_EXACT = {
|
||||||
|
"umbrel-app.yml", "docker-compose.yml", "exports.sh", "torrc",
|
||||||
|
"icon.png", "icon.svg", ".gitkeep",
|
||||||
|
}
|
||||||
|
TOEGESTAANE_MAPPEN = {"data", "hooks"}
|
||||||
|
|
||||||
|
fout = []
|
||||||
|
for naam in sorted(os.listdir(os.path.join(REPO, app))):
|
||||||
|
pad = os.path.join(REPO, app, naam)
|
||||||
|
|
||||||
|
if os.path.isdir(pad):
|
||||||
|
# __pycache__ is niet gecommit (het staat in .gitignore) en komt dus
|
||||||
|
# nooit in de kloon die umbreld ophaalt. Het staat er in een werkboom
|
||||||
|
# zodra een test de agent-template importeert, en daarover klagen zou
|
||||||
|
# de toets rood zetten op iets dat het apparaat niet bereikt.
|
||||||
|
if naam == "__pycache__":
|
||||||
|
continue
|
||||||
|
if naam not in TOEGESTAANE_MAPPEN:
|
||||||
|
fout.append(naam + "/")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if naam in TOEGESTAAN_EXACT:
|
||||||
|
continue
|
||||||
|
# Alles wat umbreld bij een update ververst is een template, en die horen
|
||||||
|
# er dus per definitie.
|
||||||
|
if naam.endswith(".template"):
|
||||||
|
continue
|
||||||
|
fout.append(naam)
|
||||||
|
|
||||||
|
u.check("%s: er staan geen werkbestanden in de app-map" % app,
|
||||||
|
not fout,
|
||||||
|
"deze horen buiten de app-map: %r" % fout)
|
||||||
|
|
||||||
|
|
||||||
|
# De variabelen die umbrelOS werkelijk invult. Alleen deze mogen in een template
|
||||||
|
# staan; zie test_templates_hebben_geen_losse_dollars. Overgenomen uit de tabel in
|
||||||
|
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md §3.
|
||||||
|
UMBREL_VARIABELEN = {
|
||||||
|
"APP_ID", "APP_VERSION", "APP_DATA_DIR", "APP_MANIFEST_FILE", "UMBREL_ROOT",
|
||||||
|
"DEVICE_HOSTNAME", "DEVICE_DOMAIN_NAME", "APP_DOMAIN",
|
||||||
|
"APP_PROXY_HOSTNAME", "APP_PROXY_PORT", "NETWORK_IP",
|
||||||
|
"TOR_PROXY_IP", "TOR_PROXY_PORT", "TOR_DATA_DIR",
|
||||||
|
"APP_HIDDEN_SERVICE", "APP_SEED", "APP_PASSWORD",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Een app krijgt daarnaast de exports van zijn afhankelijkheden, en die heten
|
||||||
|
# APP_<AFHANKELIJKHEID>_<IETS>. Electrum Gate gebruikt APP_ELECTRS_NODE_IP en
|
||||||
|
# APP_ELECTRS_NODE_PORT. Die kunnen niet in een vaste lijst staan, want welke er
|
||||||
|
# zijn hangt af van de afhankelijkheid; vandaar een vorm in plaats van een naam.
|
||||||
|
EXPORT_VORM = r"^APP_[A-Z0-9]+_[A-Z0-9_]+$"
|
||||||
|
|
||||||
|
|
||||||
|
def test_templates_hebben_geen_losse_dollars(u, app):
|
||||||
|
"""In een *.template staat geen dollarteken dat umbrelOS niet invult.
|
||||||
|
|
||||||
|
Dit is de valstrik van dit hele project, en tot 30-08-2026 beweerde het
|
||||||
|
commentaar in drie bestanden dat déze test hem dichthield terwijl dat niet zo
|
||||||
|
was.
|
||||||
|
|
||||||
|
Wat er gebeurt: umbreld haalt elke `*.template` bij het starten door envsubst.
|
||||||
|
Dat vervangt élke accolade-vorm, ook een variabele die niet bestaat, en die
|
||||||
|
wordt dan leeg. Gevolgen per bestandstype:
|
||||||
|
|
||||||
|
- in een pagina sloopt het een JavaScript-template-literal, want die gebruikt
|
||||||
|
accolades achter een dollarteken. Vandaar dat de pagina's overal strings met
|
||||||
|
een plus aan elkaar plakken;
|
||||||
|
- in een nginx-config verdwijnen `host`, `log_format` en elke variabele in een
|
||||||
|
access_log;
|
||||||
|
- in Python-code verdwijnt stilzwijgend een stuk code.
|
||||||
|
|
||||||
|
Er komt geen foutmelding. Het bestand wordt gewoon anders dan je schreef.
|
||||||
|
|
||||||
|
Beide vormen die envsubst kent worden gevlagd: `${NAAM}` en het kale `$NAAM`.
|
||||||
|
Dat tweede is de vorm waarin een nginx-variabele als `$host` geschreven wordt,
|
||||||
|
en die is hier dus net zo fout als de eerste.
|
||||||
|
|
||||||
|
Wat NIET gevlagd wordt is een los dollarteken zonder naam erachter. Dat laat
|
||||||
|
envsubst staan, en het staat in dit project in de commentaarregels die deze
|
||||||
|
regel juist uitleggen: een toets die zijn eigen uitleg rood zet, wordt
|
||||||
|
uitgezet.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
|
||||||
|
if not bestandsnaam.endswith(".template"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tekst = lees(os.path.join(REPO, app, bestandsnaam))
|
||||||
|
onbekend = []
|
||||||
|
for regelnummer, regel in enumerate(tekst.splitlines(), start=1):
|
||||||
|
for treffer in re.finditer(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", regel):
|
||||||
|
naam = treffer.group(1)
|
||||||
|
if naam in UMBREL_VARIABELEN:
|
||||||
|
continue
|
||||||
|
if re.match(EXPORT_VORM, naam):
|
||||||
|
continue
|
||||||
|
onbekend.append("regel %d: %r" % (regelnummer, treffer.group(0)))
|
||||||
|
|
||||||
|
u.check("%s/%s: geen dollartekens die umbrelOS leegmaakt" % (app, bestandsnaam),
|
||||||
|
not onbekend,
|
||||||
|
"gevonden: %r" % onbekend[:4])
|
||||||
|
|
||||||
|
|
||||||
|
def rapporteer_pinstatus(apps):
|
||||||
|
"""Afdrukken, niet toetsen. Zie de uitleg bovenaan dit bestand."""
|
||||||
|
print()
|
||||||
|
print("Pinstatus van de images (informatief, geen toets):")
|
||||||
|
for app in apps:
|
||||||
|
pad = os.path.join(REPO, app, "docker-compose.yml")
|
||||||
|
if not os.path.isfile(pad):
|
||||||
|
continue
|
||||||
|
for regel in lees(pad).splitlines():
|
||||||
|
kaal = regel.strip()
|
||||||
|
if not kaal.startswith("image:"):
|
||||||
|
continue
|
||||||
|
image = kaal.split(":", 1)[1].strip()
|
||||||
|
merk = "gepind" if "@sha256:" in image else "NIET GEPIND"
|
||||||
|
print(" %-24s %-12s %s" % (app, merk, image))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
u = Uitslag()
|
||||||
|
store_id = veld(lees(STORE), "id")
|
||||||
|
apps = app_mappen()
|
||||||
|
|
||||||
|
test_er_is_een_store_met_apps(u, store_id, apps)
|
||||||
|
for app in apps:
|
||||||
|
test_id_en_mapnaam(u, store_id, app)
|
||||||
|
test_verplichte_velden(u, app)
|
||||||
|
test_manifest_volgorde(u, app)
|
||||||
|
test_compose_bestaat_en_hangt_samen(u, app)
|
||||||
|
test_image_tag_is_de_gebouwde(u, app)
|
||||||
|
test_data_onder_data(u, app)
|
||||||
|
test_containernamen_zijn_volledig(u, app)
|
||||||
|
test_geen_werkbestanden_in_de_app_map(u, app)
|
||||||
|
test_templates_hebben_geen_losse_dollars(u, app)
|
||||||
|
|
||||||
|
rapporteer_pinstatus(apps)
|
||||||
|
return u.rapport()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Toetst dat de JavaScript in de statuspagina's parseert, en dat hij dat ook nog
|
||||||
|
// doet nadat umbreld de template heeft ingevuld.
|
||||||
|
//
|
||||||
|
// Draaien: node tests/test_paginas_parsen.mjs
|
||||||
|
//
|
||||||
|
// Waarom dit bestand er is. De pagina's zijn met bijna drieduizend regels het
|
||||||
|
// grootste deel van deze repo en er staat geen enkele toets op: alles eraan is
|
||||||
|
// handwerk in een browser. Dat blijft zo voor wat het doet, maar één klasse fouten
|
||||||
|
// hoeft niet op het apparaat gevonden te worden: een tikfout waardoor het script
|
||||||
|
// helemaal niet loopt. Het gevolg daarvan is een pagina die stil op "unknown"
|
||||||
|
// blijft staan, en dat lijkt op een agent die niet antwoordt.
|
||||||
|
//
|
||||||
|
// Aanleiding: op 30-08-2026 is de pagina van Evolu Relay in zijn geheel opnieuw
|
||||||
|
// geschreven, negenhonderd regels in één keer. Zo'n wijziging hoort niet ongetoetst
|
||||||
|
// naar een apparaat te gaan.
|
||||||
|
//
|
||||||
|
// De invulling wordt hier nagedaan en niet alleen de bron gelezen, want umbreld
|
||||||
|
// haalt elke *.template door envsubst en wat er draait is dus niet wat er staat.
|
||||||
|
// Dat vindt de gevallen waar het weghalen van een accolade-vorm de syntaxis breekt.
|
||||||
|
//
|
||||||
|
// LET OP wat deze helft NIET vindt, want dat is precies de fout die dit project
|
||||||
|
// vreest: een template-literal als `iets ${x}` wordt na de invulling `iets ` en dat
|
||||||
|
// parseert prima. Hij is dan stil kapot en niet luidruchtig. Wat dát dichthoudt is
|
||||||
|
// de dollarteken-toets in tests/test_appstore_vorm.py, die élke accolade-vorm
|
||||||
|
// vlagt die umbrelOS niet zelf invult. Deze toets is de tweede zeef en niet de
|
||||||
|
// eerste.
|
||||||
|
//
|
||||||
|
// Wat dit verder NIET toetst: of het script het juiste doet. Alleen dat het loopt.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO = join(HIER, '..');
|
||||||
|
|
||||||
|
let goed = 0;
|
||||||
|
let fout = 0;
|
||||||
|
|
||||||
|
const toets = (omschrijving, voorwaarde, uitleg) => {
|
||||||
|
if (voorwaarde) {
|
||||||
|
goed += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fout += 1;
|
||||||
|
console.log(`FOUT: ${omschrijving}${uitleg ? ` - ${uitleg}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Elke map in de repo-root met een umbrel-app.yml erin. Zelf gevonden, zoals de
|
||||||
|
* Python-toets over de store dat ook doet: een derde app valt er dan vanzelf
|
||||||
|
* onder. */
|
||||||
|
const appMappen = () =>
|
||||||
|
readdirSync(REPO, { withFileTypes: true })
|
||||||
|
.filter((regel) => regel.isDirectory())
|
||||||
|
.map((regel) => regel.name)
|
||||||
|
.filter((naam) => existsSync(join(REPO, naam, 'umbrel-app.yml')))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
/** De inhoud van elk <script>-blok zonder src-attribuut. */
|
||||||
|
const scriptBlokken = (html) => {
|
||||||
|
const blokken = [];
|
||||||
|
const patroon = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
|
let treffer = patroon.exec(html);
|
||||||
|
while (treffer !== null) {
|
||||||
|
blokken.push(treffer[1]);
|
||||||
|
treffer = patroon.exec(html);
|
||||||
|
}
|
||||||
|
return blokken;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doet na wat umbreld met een template doet.
|
||||||
|
*
|
||||||
|
* envsubst vervangt `${NAAM}` en `$NAAM` door de waarde uit de omgeving, en door
|
||||||
|
* een lege string als die variabele niet bestaat. Wat wij hier invullen is een
|
||||||
|
* plausibele waarde voor de variabelen die de pagina's gebruiken; voor de rest de
|
||||||
|
* lege string, want dat is precies wat er op het apparaat zou gebeuren.
|
||||||
|
*/
|
||||||
|
const vulIn = (tekst) => {
|
||||||
|
const WAARDEN = { APP_VERSION: '0.5.0' };
|
||||||
|
return tekst.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (heel, naam) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(WAARDEN, naam) ? WAARDEN[naam] : '',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parseert het script zonder het te draaien. Geeft de foutmelding of null. */
|
||||||
|
const parseert = (bron) => {
|
||||||
|
try {
|
||||||
|
// new vm.Script parseert bij het aanmaken en draait niets. Dat is precies wat
|
||||||
|
// hier nodig is: draaien zou een document en een window vragen die er niet
|
||||||
|
// zijn, en dan toets je je eigen namaakbrowser.
|
||||||
|
new vm.Script(bron);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return String(error && error.message ? error.message : error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waar de pagina van een app staat, en of umbreld hem invult.
|
||||||
|
*
|
||||||
|
* Twee plekken: als *.template in de app-map (dan gaat hij door envsubst), of als
|
||||||
|
* gewoon bestand in het bouwrecept onder tools/<app-id zonder store-id>/ (dan zit
|
||||||
|
* hij in een eigen image en wordt hij door niemand ingevuld). Electrum Gate is
|
||||||
|
* sinds 0.1.0 het tweede geval; zie het plan Eigenimage.
|
||||||
|
*/
|
||||||
|
const vindPagina = (app) => {
|
||||||
|
const template = join(REPO, app, 'index.html.template');
|
||||||
|
if (existsSync(template)) {
|
||||||
|
return { pad: template, template: true };
|
||||||
|
}
|
||||||
|
const kort = app.includes('-') ? app.slice(app.indexOf('-') + 1) : app;
|
||||||
|
const inImage = join(REPO, 'tools', kort, 'index.html');
|
||||||
|
if (existsSync(inImage)) {
|
||||||
|
return { pad: inImage, template: false };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const app of appMappen()) {
|
||||||
|
const pagina = vindPagina(app);
|
||||||
|
if (pagina === null) {
|
||||||
|
// Geen pagina is geen fout: een app hoeft er geen te hebben.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bron = readFileSync(pagina.pad, 'utf8');
|
||||||
|
const blokken = scriptBlokken(bron);
|
||||||
|
|
||||||
|
toets(`${app}: de pagina heeft een scriptblok`, blokken.length > 0);
|
||||||
|
|
||||||
|
blokken.forEach((blok, index) => {
|
||||||
|
const naam = `${app}: scriptblok ${index + 1}`;
|
||||||
|
|
||||||
|
const rauw = parseert(blok);
|
||||||
|
toets(`${naam} parseert zoals hij in de repo staat`, rauw === null, rauw);
|
||||||
|
|
||||||
|
if (pagina.template) {
|
||||||
|
const ingevuld = parseert(vulIn(blok));
|
||||||
|
toets(`${naam} parseert ook nadat umbreld hem invult`, ingevuld === null, ingevuld);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pagina.template) {
|
||||||
|
// Een pagina in een image gaat door geen envsubst meer, dus wat er staat is
|
||||||
|
// wat de browser krijgt. Een accolade-variabele is dan een restant uit de
|
||||||
|
// template-tijd die letterlijk op de pagina zou belanden.
|
||||||
|
toets(`${app}: de pagina in de image bevat geen accolade-variabele`, !bron.includes('${'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// De HTML zelf: geen parser, maar wel de fout die je in een bestand van
|
||||||
|
// tweeduizend regels echt maakt. Een dialog of een header die niet gesloten is,
|
||||||
|
// laat de rest van de pagina in dat element hangen.
|
||||||
|
for (const tag of ['html', 'head', 'body', 'header', 'script', 'style', 'dialog', 'details']) {
|
||||||
|
const open = (bron.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length;
|
||||||
|
const dicht = (bron.match(new RegExp(`</${tag}>`, 'gi')) || []).length;
|
||||||
|
if (open === 0 && dicht === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toets(
|
||||||
|
`${app}: elke <${tag}> is gesloten`,
|
||||||
|
open === dicht,
|
||||||
|
`${open} keer open, ${dicht} keer dicht`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`${goed} goed, ${fout} fout`);
|
||||||
|
process.exit(fout === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Toetst de agent van Evolu Relay: de labels.
|
||||||
|
|
||||||
|
Waarom deze test bestaat. De agent van deze app beslist niets over toegang, en dat
|
||||||
|
is precies waarom hij tot 30-08-2026 ongetoetst kon blijven: hij las een bestand en
|
||||||
|
legde opdrachten in een postbus, en het beleid eromheen staat in `policy.js` met
|
||||||
|
`tests/test_limiter.mjs` erop. Met deze release doet hij twee dingen erbij die
|
||||||
|
eigen logica hebben:
|
||||||
|
|
||||||
|
- **hij schrijft zelf een bestand**, `labels.json`, en is daar de enige schrijver
|
||||||
|
van. Dat is de eerste plek waar de agent staat bijhoudt in plaats van doorgeeft,
|
||||||
|
en labelen is lezen-wijzigen-schrijven, dus er zit een slot om;
|
||||||
|
Tot 0.6.0 rekende hij daarnaast het tijdvenster voor nieuwe eigenaars uit. Dat
|
||||||
|
venster is er in 0.7.0 uit: er is nog één weg naar binnen, en dat is de wachtlijst
|
||||||
|
met handmatig toelaten. De toetsen daarover zijn met de functie mee verdwenen.
|
||||||
|
|
||||||
|
Wat deze test NIET dekt: de HTTP-laag. De handlers zitten in een
|
||||||
|
BaseHTTPRequestHandler en die is zonder socket niet aan te roepen; wat eronder
|
||||||
|
hangt, de validatie en het schrijven, is hier wél getoetst. De pagina zelf blijft
|
||||||
|
handwerk in een browser.
|
||||||
|
|
||||||
|
Draaien:
|
||||||
|
|
||||||
|
python tests/test_relay_agent.py
|
||||||
|
|
||||||
|
De test laadt `tools/evolu-relay/agent.py` rechtstreeks: het bestand dat de
|
||||||
|
Dockerfile in de image zet. Tot 0.6.0 heette dat `agent.py.template` en stond het
|
||||||
|
in de app-map; de eerste toets hieronder is uit die tijd en blijft staan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Vóór de imports, want anders is het te laat: Python legt bytecode naast agent.py
|
||||||
|
# zodra die geïmporteerd wordt, en die rommel hoort niet in de repo. Een keer is
|
||||||
|
# zo'n .pyc meegegaan in een commit.
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
|
||||||
|
import importlib.machinery # noqa: E402
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
import json # noqa: E402
|
||||||
|
import os # noqa: E402
|
||||||
|
import tempfile # noqa: E402
|
||||||
|
from datetime import datetime, timedelta, timezone # noqa: E402
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
AGENT = os.path.join(HERE, os.pardir, "tools", "evolu-relay", "agent.py")
|
||||||
|
|
||||||
|
|
||||||
|
def load_agent(state_dir):
|
||||||
|
"""Laadt agent.py als module, met zijn staat in een tijdelijke map.
|
||||||
|
|
||||||
|
De agent leest `RELAY_STATE_DIR` op moduleniveau, dus die moet vóór het laden
|
||||||
|
in de omgeving staan. Met een expliciete loader, buiten sys.path om.
|
||||||
|
"""
|
||||||
|
os.environ["RELAY_STATE_DIR"] = state_dir
|
||||||
|
loader = importlib.machinery.SourceFileLoader("relay_agent", AGENT)
|
||||||
|
spec = importlib.util.spec_from_file_location("relay_agent", AGENT, loader=loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class Uitslag:
|
||||||
|
def __init__(self):
|
||||||
|
self.goed = 0
|
||||||
|
self.fout = []
|
||||||
|
|
||||||
|
def check(self, naam, gelukt, uitleg=""):
|
||||||
|
if gelukt:
|
||||||
|
self.goed += 1
|
||||||
|
else:
|
||||||
|
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
|
||||||
|
|
||||||
|
def rapport(self):
|
||||||
|
print()
|
||||||
|
print("%d goed, %d fout" % (self.goed, len(self.fout)))
|
||||||
|
for f in self.fout:
|
||||||
|
print(" FOUT: " + f)
|
||||||
|
return 0 if not self.fout else 1
|
||||||
|
|
||||||
|
|
||||||
|
def iso(seconden_vanaf_nu):
|
||||||
|
"""Een tijdstip in de vorm die het relay-proces schrijft: ISO, met een Z."""
|
||||||
|
when = datetime.now(timezone.utc) + timedelta(seconds=seconden_vanaf_nu)
|
||||||
|
return when.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def schrijf_owners(state_dir, staat):
|
||||||
|
with open(os.path.join(state_dir, "owners.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(staat, f)
|
||||||
|
|
||||||
|
|
||||||
|
# ── De aanname waar deze hele test op rust ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_template_is_invulbaar_zonder_schade(u):
|
||||||
|
"""Een restant uit de template-tijd dat blijft staan.
|
||||||
|
|
||||||
|
Het bestand zit sinds 0.6.0 in de image en gaat door geen envsubst meer. Maar
|
||||||
|
als iemand het ooit weer als template in een app-map zet, is dit de toets die
|
||||||
|
het meteen ziet.
|
||||||
|
"""
|
||||||
|
with open(AGENT, "r", encoding="utf-8") as f:
|
||||||
|
inhoud = f.read()
|
||||||
|
u.check("agent.py bevat geen dollartekens",
|
||||||
|
"$" not in inhoud,
|
||||||
|
"een envsubst-stap zou die invullen en de Python-code slopen")
|
||||||
|
|
||||||
|
|
||||||
|
# ── De labels ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_label_zetten_en_weghalen(agent, u):
|
||||||
|
u.check("een label zetten lukt", agent.apply_label("owner-a", "Laptop") is None)
|
||||||
|
u.check("en staat er daarna", agent.read_labels().get("owner-a") == "Laptop")
|
||||||
|
|
||||||
|
u.check("overschrijven lukt", agent.apply_label("owner-a", "Werklaptop") is None)
|
||||||
|
u.check("en vervangt de vorige", agent.read_labels().get("owner-a") == "Werklaptop")
|
||||||
|
|
||||||
|
u.check("een leeg label haalt hem weg", agent.apply_label("owner-a", "") is None)
|
||||||
|
u.check("en dan is hij er niet meer", "owner-a" not in agent.read_labels())
|
||||||
|
|
||||||
|
u.check("nog een keer weghalen is geen fout",
|
||||||
|
agent.apply_label("owner-a", "") is None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_blijft_op_schijf(agent, u, state_dir):
|
||||||
|
"""Het bestand is JSON en te lezen zonder de agent.
|
||||||
|
|
||||||
|
Dat is geen formaliteit: dit bestand staat onder de app-datamap en gaat mee in
|
||||||
|
de back-up van umbrelOS. Een gebruiker die zijn labels kwijt is, moet ze daar
|
||||||
|
kunnen terugvinden.
|
||||||
|
"""
|
||||||
|
agent.apply_label("owner-b", "Telefoon")
|
||||||
|
with open(os.path.join(state_dir, "labels.json"), "r", encoding="utf-8") as f:
|
||||||
|
op_schijf = json.load(f)
|
||||||
|
u.check("het label staat in labels.json", op_schijf.get("owner-b") == "Telefoon")
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_validatie(agent, u):
|
||||||
|
goed = agent.valid_label({"ownerId": "owner-c", "label": " Mijn laptop "})
|
||||||
|
u.check("witruimte wordt samengetrokken", goed[1] == "Mijn laptop")
|
||||||
|
u.check("en er is geen fout", goed[2] is None)
|
||||||
|
|
||||||
|
# Regeleindes eruit: dit is één regel naast een id, en een label met een nieuwe
|
||||||
|
# regel erin zou de lijst uit elkaar trekken.
|
||||||
|
plat = agent.valid_label({"ownerId": "owner-c", "label": "een\ntwee"})
|
||||||
|
u.check("een regeleinde wordt een spatie", plat[1] == "een twee")
|
||||||
|
|
||||||
|
leeg = agent.valid_label({"ownerId": "owner-c"})
|
||||||
|
u.check("een ontbrekend label is leeg en geen fout", leeg[1] == "" and leeg[2] is None)
|
||||||
|
|
||||||
|
for rommel, waarom in [
|
||||||
|
({}, "geen ownerId"),
|
||||||
|
({"ownerId": ""}, "leeg ownerId"),
|
||||||
|
({"ownerId": 42}, "ownerId is geen tekst"),
|
||||||
|
({"ownerId": "x" * (agent.MAX_OWNER_ID_LENGTH + 1)}, "ownerId te lang"),
|
||||||
|
({"ownerId": "owner-c", "label": 42}, "label is geen tekst"),
|
||||||
|
({"ownerId": "owner-c", "label": "x" * (agent.MAX_LABEL_LENGTH + 1)}, "label te lang"),
|
||||||
|
("geen object", "geen object"),
|
||||||
|
]:
|
||||||
|
u.check("een onbruikbaar label wordt geweigerd: %s" % waarom,
|
||||||
|
agent.valid_label(rommel)[2] is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_lopen_niet_vol(agent, u):
|
||||||
|
"""Er zit een plafond op. Niet tegen een aanvaller, want deze pagina zit achter
|
||||||
|
de inlog van umbrelOS, maar tegen een lus die per ongeluk blijft schrijven.
|
||||||
|
"""
|
||||||
|
for i in range(agent.MAX_LABELS):
|
||||||
|
agent.apply_label("bulk-%d" % i, "label %d" % i)
|
||||||
|
|
||||||
|
u.check("het plafond is bereikt", len(agent.read_labels()) == agent.MAX_LABELS)
|
||||||
|
u.check("en een label erboven wordt geweigerd",
|
||||||
|
agent.apply_label("een-te-veel", "nog een") is not None)
|
||||||
|
u.check("maar een bestaand label mag nog wél gewijzigd worden",
|
||||||
|
agent.apply_label("bulk-0", "gewijzigd") is None,
|
||||||
|
"anders kun je bij een vol bestand niets meer verbeteren")
|
||||||
|
|
||||||
|
|
||||||
|
def test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir):
|
||||||
|
"""Anders dan owners.json: daar hangt aan een half begrepen bestand de vraag wie
|
||||||
|
er binnen mag, en dan is weigeren het antwoord. Hier gaat het om een naam naast
|
||||||
|
een id, en het ergste gevolg is dat je de rauwe ids ziet.
|
||||||
|
"""
|
||||||
|
with open(os.path.join(state_dir, "labels.json"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("dit is geen json {{{")
|
||||||
|
u.check("een onleesbaar labelbestand leest als leeg", agent.read_labels() == {})
|
||||||
|
|
||||||
|
with open(os.path.join(state_dir, "labels.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"owner-d": "Goed", "owner-e": 42, "": "geen id", "owner-f": " "}, f)
|
||||||
|
labels = agent.read_labels()
|
||||||
|
u.check("de goede regel blijft", labels.get("owner-d") == "Goed")
|
||||||
|
u.check("een label dat geen tekst is valt weg", "owner-e" not in labels)
|
||||||
|
u.check("een leeg id valt weg", "" not in labels)
|
||||||
|
u.check("een label van alleen witruimte valt weg", "owner-f" not in labels)
|
||||||
|
|
||||||
|
|
||||||
|
# ── De status ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
|
||||||
|
"""De pagina krijgt het label bij de eigenaar, en het bestand van de relay
|
||||||
|
blijft ongemoeid. Dat tweede is de reden dat `met_label` een kopie maakt.
|
||||||
|
"""
|
||||||
|
schrijf_owners(state_dir, {
|
||||||
|
"version": 1,
|
||||||
|
"learning": False,
|
||||||
|
"learningUntil": None,
|
||||||
|
"owners": [
|
||||||
|
{"id": "owner-g", "allowed": True, "firstSeen": iso(-600), "lastSeen": iso(-60)},
|
||||||
|
{"id": "owner-h", "allowed": False, "firstSeen": iso(-900), "lastSeen": None},
|
||||||
|
],
|
||||||
|
"rejected": [{"id": "owner-i", "firstSeen": iso(-300), "lastSeen": iso(-10),
|
||||||
|
"attempts": 3}],
|
||||||
|
})
|
||||||
|
agent.apply_label("owner-g", "Laptop")
|
||||||
|
agent.apply_label("owner-i", "Onbekend apparaat")
|
||||||
|
|
||||||
|
status = agent.build_status()
|
||||||
|
owners = status["owners"]
|
||||||
|
|
||||||
|
u.check("de toegelaten eigenaar krijgt zijn label",
|
||||||
|
owners["allowed"][0]["label"] == "Laptop")
|
||||||
|
u.check("de geblokkeerde staat in blocked", owners["blocked"][0]["id"] == "owner-h")
|
||||||
|
u.check("en heeft geen label", owners["blocked"][0]["label"] is None)
|
||||||
|
u.check("de geweigerde poging staat in rejected", owners["rejected"][0]["id"] == "owner-i")
|
||||||
|
u.check("en krijgt zijn label", owners["rejected"][0]["label"] == "Onbekend apparaat")
|
||||||
|
u.check("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3)
|
||||||
|
|
||||||
|
# De leerstand is er in 0.7.0 uit. Deze toets houdt vast dat de agent hem ook
|
||||||
|
# niet meer dóórgeeft: een pagina die het veld nog zou lezen, zou anders een
|
||||||
|
# knop kunnen tonen voor iets dat niet meer bestaat.
|
||||||
|
u.check("de leerstand staat niet meer in de status",
|
||||||
|
"learning" not in owners and "learningSecondsLeft" not in owners)
|
||||||
|
|
||||||
|
with open(os.path.join(state_dir, "owners.json"), "r", encoding="utf-8") as f:
|
||||||
|
op_schijf = json.load(f)
|
||||||
|
u.check("owners.json is niet aangeraakt",
|
||||||
|
all("label" not in regel for regel in op_schijf["owners"]),
|
||||||
|
"de agent hoort niet in het bestand van de relay te schrijven")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
u = Uitslag()
|
||||||
|
test_template_is_invulbaar_zonder_schade(u)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as state_dir:
|
||||||
|
agent = load_agent(state_dir)
|
||||||
|
|
||||||
|
|
||||||
|
test_label_zetten_en_weghalen(agent, u)
|
||||||
|
test_label_blijft_op_schijf(agent, u, state_dir)
|
||||||
|
test_label_validatie(agent, u)
|
||||||
|
test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir)
|
||||||
|
|
||||||
|
test_status_hangt_labels_aan_de_regels(agent, u, state_dir)
|
||||||
|
|
||||||
|
# Als laatste: deze vult het labelbestand tot het plafond en laat dus geen
|
||||||
|
# bruikbare staat achter voor een toets erna.
|
||||||
|
test_labels_lopen_niet_vol(agent, u)
|
||||||
|
|
||||||
|
return u.rapport()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Toetst het herschrijven in tools/release-bump.mjs.
|
||||||
|
//
|
||||||
|
// Draaien: node tests/test_release_bump.mjs
|
||||||
|
//
|
||||||
|
// WAAROM DIT BESTAND ER IS. Dat script bestaat om één stille fout onmogelijk te
|
||||||
|
// maken: een OUDE digest onder een NIEUWE tag levert de oude image en er is niets
|
||||||
|
// dat dat meldt. Een script dat die fout zelf maakt is erger dan overtypen, want
|
||||||
|
// dan vertrouw je erop. Vandaar dat de vervanging hier op de niet-gelukkige paden
|
||||||
|
// getoetst wordt en niet alleen op het geval waarin alles klopt.
|
||||||
|
//
|
||||||
|
// De netwerkcontrole (hoort die digest bij die tag) staat hier NIET in: die vraagt
|
||||||
|
// het register en dat is geen toets maar een meting. Wat hier staat is wat zonder
|
||||||
|
// netwerk te bewijzen valt.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
herschrijfImage, herschrijfVersie, huidigeAppVersie, kiesAppVersie, isHoger,
|
||||||
|
} from '../tools/release-bump.mjs';
|
||||||
|
|
||||||
|
const DIGEST = `sha256:${'a'.repeat(64)}`;
|
||||||
|
const OUD = `sha256:${'b'.repeat(64)}`;
|
||||||
|
|
||||||
|
function test(naam, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ok ${naam}`);
|
||||||
|
} catch (fout) {
|
||||||
|
console.log(` FOUT ${naam}`);
|
||||||
|
console.log(` ${fout.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('herschrijfImage');
|
||||||
|
|
||||||
|
test('vervangt de hele regel, dus de oude digest kan niet blijven staan', () => {
|
||||||
|
const compose = `services:\n server:\n image: sc.kamenier-hamer.nl/sysop/atreides-assets:0.1.0@${OUD}\n restart: on-failure\n`;
|
||||||
|
const { tekst, naam, aantal } = herschrijfImage(compose, '0.1.3', DIGEST);
|
||||||
|
assert.equal(naam, 'sc.kamenier-hamer.nl/sysop/atreides-assets');
|
||||||
|
assert.equal(aantal, 1);
|
||||||
|
assert.ok(tekst.includes(`atreides-assets:0.1.3@${DIGEST}`));
|
||||||
|
assert.ok(!tekst.includes(OUD), 'de oude digest staat er nog');
|
||||||
|
assert.ok(tekst.includes(' restart: on-failure'), 'de rest van het bestand is geraakt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en houdt de inspringing aan die er stond', () => {
|
||||||
|
const compose = ` image: sc.kamenier-hamer.nl/sysop/x:0.1.0\n`;
|
||||||
|
assert.equal(herschrijfImage(compose, '0.2.0', DIGEST).tekst, ` image: sc.kamenier-hamer.nl/sysop/x:0.2.0@${DIGEST}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neemt twee containers uit hetzelfde image samen mee', () => {
|
||||||
|
const compose = ` a:\n image: sc.kamenier-hamer.nl/sysop/gate:0.1.0@${OUD}\n b:\n image: sc.kamenier-hamer.nl/sysop/gate:0.1.0@${OUD}\n`;
|
||||||
|
const { aantal, tekst } = herschrijfImage(compose, '0.1.1', DIGEST);
|
||||||
|
assert.equal(aantal, 2);
|
||||||
|
assert.equal(tekst.match(/0\.1\.1/g).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('laat een image van een ANDERE host met rust', () => {
|
||||||
|
const compose = ` db:\n image: postgres:16-alpine\n app:\n image: sc.kamenier-hamer.nl/sysop/relay:0.7.1\n`;
|
||||||
|
const { tekst, aantal } = herschrijfImage(compose, '0.7.2', DIGEST);
|
||||||
|
assert.equal(aantal, 1);
|
||||||
|
assert.ok(tekst.includes('image: postgres:16-alpine'), 'de vreemde image is meeverschoven');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weigert een compose zonder eigen image in plaats van er een bij te zetten', () => {
|
||||||
|
assert.throws(() => herschrijfImage(' db:\n image: postgres:16\n', '1.0.0', DIGEST), /geen image-regel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weigert twee verschillende eigen images, want dan is de bedoeling niet af te leiden', () => {
|
||||||
|
const compose = ` image: sc.kamenier-hamer.nl/sysop/a:0.1.0\n image: sc.kamenier-hamer.nl/sysop/b:0.1.0\n`;
|
||||||
|
assert.throws(() => herschrijfImage(compose, '0.2.0', DIGEST), /meerdere eigen images/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weigert een digest die niet de vorm heeft, want dat is de plakfout die telt', () => {
|
||||||
|
const compose = ` image: sc.kamenier-hamer.nl/sysop/x:0.1.0\n`;
|
||||||
|
assert.throws(() => herschrijfImage(compose, '0.2.0', 'sha256:kort'), /digest heeft niet de vorm/);
|
||||||
|
assert.throws(() => herschrijfImage(compose, '0.2.0', 'a'.repeat(64)), /digest heeft niet de vorm/);
|
||||||
|
assert.throws(() => herschrijfImage(compose, 'v0.2.0', DIGEST), /versie heeft niet de vorm/);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('de app-versie');
|
||||||
|
|
||||||
|
test('leest de huidige versie, met en zonder aanhalingstekens', () => {
|
||||||
|
assert.equal(huidigeAppVersie('id: x\nversion: "0.1.2"\nname: X\n'), '0.1.2');
|
||||||
|
assert.equal(huidigeAppVersie('version: 1.2.3\n'), '1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vergelijkt per deel en niet als tekst', () => {
|
||||||
|
assert.equal(isHoger('0.10.0', '0.9.0'), true);
|
||||||
|
assert.equal(isHoger('0.9.0', '0.10.0'), false);
|
||||||
|
assert.equal(isHoger('1.0.0', '0.99.99'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trekt gelijk als de image erboven ligt, en verhoogt anders de patch', () => {
|
||||||
|
assert.equal(kiesAppVersie('0.1.2', '0.1.3'), '0.1.3');
|
||||||
|
assert.equal(kiesAppVersie('0.2.0', '0.1.3'), '0.2.1');
|
||||||
|
// Gelijk is ook een verhoging waard: zonder verhoging rolt umbrelOS niets uit.
|
||||||
|
assert.equal(kiesAppVersie('0.1.3', '0.1.3'), '0.1.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schrijft de versie met aanhalingstekens terug en laat de rest staan', () => {
|
||||||
|
const manifest = 'id: x\nversion: "0.1.2"\nname: X\n';
|
||||||
|
assert.equal(herschrijfVersie(manifest, '0.1.3'), 'id: x\nversion: "0.1.3"\nname: X\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weigert een manifest zonder versie in plaats van er een bij te zetten', () => {
|
||||||
|
assert.throws(() => herschrijfVersie('id: x\nname: X\n', '0.1.3'), /geen `version:`/);
|
||||||
|
assert.throws(() => huidigeAppVersie('id: x\n'), /geen `version:`/);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.exitCode) console.log('\nER ZIJN FOUTEN');
|
||||||
|
else console.log('\nalles goed');
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
"""Toetst dat de pagina omhoog komt zonder certificaat.
|
||||||
|
|
||||||
|
Waarom deze test bestaat. In 0.0.3 startte de app niet: het stream-blok van
|
||||||
|
nginx had een 'listen 50022 ssl' met een include naar de cert.conf van de agent,
|
||||||
|
en nginx weigert te starten als dat certificaat er niet is. Daardoor kwam ook de
|
||||||
|
web-UI niet omhoog, en dat is precies de pagina waarop je een certificaat kiest.
|
||||||
|
Wie geen reverse proxy draait, of twee kandidaten heeft, zag dus niets en kon
|
||||||
|
niets. De symptomen wezen ergens anders heen: de app_proxy meldde alleen dat de
|
||||||
|
server niet te bereiken was.
|
||||||
|
|
||||||
|
De reparatie is een verplaatsing, en die is niet aan de code af te lezen: het
|
||||||
|
stream-blok staat in stream.conf en wordt door entrypoint.sh pas in
|
||||||
|
/var/lib/gate/tls/ gezet als cert.conf bestaat. nginx.conf haalt die map op met
|
||||||
|
een jokerteken, en een jokerteken dat niets matcht is voor nginx geen fout. Eén
|
||||||
|
iemand die dat blok "netjes" terugzet in nginx.conf en de klem is terug, zonder
|
||||||
|
dat een van de andere toetsen iets merkt.
|
||||||
|
|
||||||
|
Sinds 0.1.0 zitten deze bestanden in een eigen image en staan ze in
|
||||||
|
tools/electrum-gate/; tot dan waren het *.template-bestanden in de app-map en
|
||||||
|
was het startscript een command-blok in de compose. De toetsen zijn mee
|
||||||
|
verhuisd en toetsen hetzelfde gedrag, plus wat er bij die verhuizing nieuw
|
||||||
|
kapot kan: de Dockerfile die een bestand niet meeneemt, en de plaatshouders in
|
||||||
|
stream.conf die entrypoint.sh moet invullen.
|
||||||
|
|
||||||
|
Draaien:
|
||||||
|
|
||||||
|
python tests/test_server_start_zonder_certificaat.py
|
||||||
|
|
||||||
|
Er staan een paar toetsen bij die niet over het starten gaan maar wel over
|
||||||
|
dezelfde soort fout: een die niets meldt en pas opvalt als iemand het toevallig
|
||||||
|
ziet. De verbindingsteller die stil nul telt, een mount die weer alleen-lezen wordt,
|
||||||
|
en twee taglines die uit elkaar lopen. Ze staan hier en niet in een derde bestand,
|
||||||
|
want de suite heeft geen runner en een bestand dat niemand aanroept toetst niets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
|
||||||
|
import os # noqa: E402
|
||||||
|
import re # noqa: E402
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
APP = os.path.join(HERE, os.pardir, "whatsnext-electrum-gate")
|
||||||
|
RECEPT = os.path.join(HERE, os.pardir, "tools", "electrum-gate")
|
||||||
|
|
||||||
|
NGINX_CONF = os.path.join(RECEPT, "nginx.conf")
|
||||||
|
STREAM_CONF = os.path.join(RECEPT, "stream.conf")
|
||||||
|
ENTRYPOINT = os.path.join(RECEPT, "entrypoint.sh")
|
||||||
|
DOCKERFILE = os.path.join(RECEPT, "Dockerfile")
|
||||||
|
BUILD = os.path.join(RECEPT, "build.sh")
|
||||||
|
PAGINA = os.path.join(RECEPT, "index.html")
|
||||||
|
COMPOSE = os.path.join(APP, "docker-compose.yml")
|
||||||
|
MANIFEST = os.path.join(APP, "umbrel-app.yml")
|
||||||
|
|
||||||
|
|
||||||
|
class Uitslag:
|
||||||
|
def __init__(self):
|
||||||
|
self.goed = 0
|
||||||
|
self.fout = []
|
||||||
|
|
||||||
|
def check(self, naam, gelukt, uitleg=""):
|
||||||
|
if gelukt:
|
||||||
|
self.goed += 1
|
||||||
|
else:
|
||||||
|
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
|
||||||
|
|
||||||
|
def rapport(self):
|
||||||
|
print()
|
||||||
|
print("%d goed, %d fout" % (self.goed, len(self.fout)))
|
||||||
|
for f in self.fout:
|
||||||
|
print(" FOUT: " + f)
|
||||||
|
return 0 if not self.fout else 1
|
||||||
|
|
||||||
|
|
||||||
|
def lees(pad):
|
||||||
|
with open(pad, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def zonder_commentaar(tekst):
|
||||||
|
"""De regels die nginx of de shell daadwerkelijk uitvoert.
|
||||||
|
|
||||||
|
Zonder dit zou elke toets hieronder afgaan op de uitleg erboven, en die
|
||||||
|
noemt juist de dingen die er niet meer mogen staan.
|
||||||
|
"""
|
||||||
|
regels = []
|
||||||
|
for regel in tekst.splitlines():
|
||||||
|
kaal = regel.strip()
|
||||||
|
if not kaal or kaal.startswith("#"):
|
||||||
|
continue
|
||||||
|
regels.append(kaal)
|
||||||
|
return regels
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_hangt_niet_aan_een_certificaat(u):
|
||||||
|
"""Geen TLS-directive in nginx.conf, want die blokkeert de start."""
|
||||||
|
regels = zonder_commentaar(lees(NGINX_CONF))
|
||||||
|
|
||||||
|
u.check("nginx.conf heeft geen listen met ssl",
|
||||||
|
not any("listen" in r and "ssl" in r for r in regels),
|
||||||
|
"een 'listen ssl' zonder certificaat laat nginx niet starten, "
|
||||||
|
"en dan is er ook geen pagina om er een te kiezen")
|
||||||
|
|
||||||
|
u.check("nginx.conf includet cert.conf niet rechtstreeks",
|
||||||
|
not any("cert.conf" in r for r in regels),
|
||||||
|
"een include van een bestand dat er nog niet is, is een startfout")
|
||||||
|
|
||||||
|
u.check("nginx.conf haalt het stream-blok met een jokerteken op",
|
||||||
|
any(r.startswith("include") and "/var/lib/gate/tls/*" in r
|
||||||
|
for r in regels),
|
||||||
|
"zonder jokerteken is een lege map alsnog een startfout")
|
||||||
|
|
||||||
|
u.check("de pagina zelf luistert nog wel",
|
||||||
|
any(r.startswith("listen 80") for r in regels))
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_blok_staat_apart(u):
|
||||||
|
"""Het TLS-deel is compleet, en het backend-adres komt uit de omgeving."""
|
||||||
|
u.check("stream.conf bestaat", os.path.isfile(STREAM_CONF))
|
||||||
|
if not os.path.isfile(STREAM_CONF):
|
||||||
|
return
|
||||||
|
|
||||||
|
regels = zonder_commentaar(lees(STREAM_CONF))
|
||||||
|
|
||||||
|
u.check("stream.conf luistert op 50022 met ssl",
|
||||||
|
any("listen 50022 ssl" in r for r in regels))
|
||||||
|
u.check("stream.conf includet de cert.conf van de agent",
|
||||||
|
any("include /var/lib/gate/cert.conf" in r for r in regels))
|
||||||
|
u.check("stream.conf heeft zijn log_format zelf",
|
||||||
|
any(r.startswith("log_format gate") for r in regels),
|
||||||
|
"tot 0.0.29 schreef het command-blok dat in een los bestand, omdat "
|
||||||
|
"een dollarteken in een template leeggemaakt werd; dat hoeft niet meer")
|
||||||
|
u.check("en schrijft de sessielog waar de agent hem leest",
|
||||||
|
any(r.startswith("access_log /var/lib/gate/stream.log") for r in regels))
|
||||||
|
|
||||||
|
# Het adres van de backend staat niet in dit bestand maar komt uit de
|
||||||
|
# omgeving, en de weg daarheen is een plaatshouder die entrypoint.sh invult.
|
||||||
|
# Geen dollarteken in die plaatshouder: dan kan geen envsubst-stap, van
|
||||||
|
# umbreld of van wie dan ook, hem stil leegmaken.
|
||||||
|
proxy = [r for r in regels if r.startswith("proxy_pass")]
|
||||||
|
u.check("stream.conf heeft precies één proxy_pass", len(proxy) == 1,
|
||||||
|
"gevonden: %r" % proxy)
|
||||||
|
u.check("en die verwijst naar de plaatshouders voor host en poort",
|
||||||
|
proxy and "__GATE_ELECTRUM_HOST__:__GATE_ELECTRUM_PORT__" in proxy[0],
|
||||||
|
"gevonden: %r" % proxy)
|
||||||
|
|
||||||
|
u.check("stream.conf bevat geen accolade-variabele meer",
|
||||||
|
"${" not in lees(STREAM_CONF),
|
||||||
|
"die wordt sinds de eigen image door niemand meer ingevuld en zou "
|
||||||
|
"letterlijk in de configuratie belanden")
|
||||||
|
|
||||||
|
|
||||||
|
def test_entrypoint_wacht_niet_op_de_agent(u):
|
||||||
|
"""entrypoint.sh start nginx altijd, en zet TLS erbij als het kan."""
|
||||||
|
u.check("entrypoint.sh bestaat", os.path.isfile(ENTRYPOINT))
|
||||||
|
if not os.path.isfile(ENTRYPOINT):
|
||||||
|
return
|
||||||
|
|
||||||
|
tekst = lees(ENTRYPOINT)
|
||||||
|
regels = zonder_commentaar(tekst)
|
||||||
|
|
||||||
|
u.check("geen wachtlus op cert.conf",
|
||||||
|
not any("! -f" in r and "cert.conf" in r and "while" in r
|
||||||
|
for r in regels),
|
||||||
|
"dat is precies de klem uit 0.0.3: geen certificaat, geen nginx, "
|
||||||
|
"geen pagina, dus geen manier om een certificaat te kiezen")
|
||||||
|
|
||||||
|
u.check("het stream-blok wordt ingevuld uit de omgeving",
|
||||||
|
any("__GATE_ELECTRUM_HOST__" in r and "GATE_ELECTRUM_HOST" in r
|
||||||
|
for r in regels)
|
||||||
|
and any("__GATE_ELECTRUM_PORT__" in r for r in regels),
|
||||||
|
"anders staat de plaatshouder letterlijk in de proxy_pass")
|
||||||
|
|
||||||
|
u.check("en wordt uit stream.conf.in gelezen, zoals de Dockerfile hem neerzet",
|
||||||
|
any("/etc/nginx/stream.conf.in" in r for r in regels))
|
||||||
|
|
||||||
|
u.check("het stream-blok wordt weggezet als cert.conf bestaat",
|
||||||
|
any(r.startswith("if cp /etc/nginx/stream.conf") and "/tls/" in r
|
||||||
|
for r in regels))
|
||||||
|
|
||||||
|
u.check("en weggehaald als cert.conf er niet is",
|
||||||
|
any(r.startswith("rm -f") and "/tls/stream.conf" in r for r in regels),
|
||||||
|
"een ingetrokken keuze moet de poort ook echt sluiten")
|
||||||
|
|
||||||
|
u.check("een mislukte herlading breekt de wachtlus niet af",
|
||||||
|
any("if ! nginx -s reload" in r for r in regels),
|
||||||
|
"onder 'set -e' zou de lus verdwijnen en daarna pikt niets meer "
|
||||||
|
"een wijziging op")
|
||||||
|
|
||||||
|
u.check("nginx wordt met exec het hoofdproces",
|
||||||
|
regels and regels[-1].startswith("exec nginx"),
|
||||||
|
"anders blijft de shell PID 1, geeft signalen niet door, en ziet "
|
||||||
|
"Docker een geslaagde afsluiting als nginx omvalt; laatste regel: %r"
|
||||||
|
% (regels[-1] if regels else None))
|
||||||
|
|
||||||
|
u.check("het script begint met een shebang",
|
||||||
|
tekst.startswith("#!/bin/sh"))
|
||||||
|
|
||||||
|
u.check("geen verdubbelde dollartekens meer",
|
||||||
|
"$$" not in "\n".join(regels),
|
||||||
|
"dat was de ontsnapping voor het command-blok in de compose; in een "
|
||||||
|
"gewoon script is $$ het proces-id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_neemt_alles_mee(u):
|
||||||
|
"""Wat entrypoint.sh en nginx.conf verwachten, zet de Dockerfile ook neer.
|
||||||
|
|
||||||
|
Dit is de nieuwe klasse fouten van de eigen image: een bestand dat in de
|
||||||
|
repo staat maar niet in de image, en dan faalt de container pas op het
|
||||||
|
apparaat. Geen Docker nodig om dat te zien; de paden staan in de tekst.
|
||||||
|
"""
|
||||||
|
u.check("Dockerfile bestaat", os.path.isfile(DOCKERFILE))
|
||||||
|
if not os.path.isfile(DOCKERFILE):
|
||||||
|
return
|
||||||
|
|
||||||
|
regels = zonder_commentaar(lees(DOCKERFILE))
|
||||||
|
copies = [r for r in regels if r.startswith("COPY ")]
|
||||||
|
|
||||||
|
def kopieert(bron, doel):
|
||||||
|
return any(bron in r and r.endswith(" " + doel) for r in copies)
|
||||||
|
|
||||||
|
u.check("nginx.conf gaat naar /etc/nginx/nginx.conf",
|
||||||
|
kopieert("tools/electrum-gate/nginx.conf", "/etc/nginx/nginx.conf"))
|
||||||
|
u.check("stream.conf gaat naar /etc/nginx/stream.conf.in",
|
||||||
|
kopieert("tools/electrum-gate/stream.conf", "/etc/nginx/stream.conf.in"),
|
||||||
|
"entrypoint.sh leest dat pad en vult het in naar stream.conf")
|
||||||
|
u.check("de pagina gaat naar de webroot",
|
||||||
|
kopieert("tools/electrum-gate/index.html", "/usr/share/nginx/html/index.html"))
|
||||||
|
u.check("het icoon komt uit de app-map",
|
||||||
|
kopieert("whatsnext-electrum-gate/icon.png", "/usr/share/nginx/html/icon.png"),
|
||||||
|
"de app-map houdt het origineel omdat het manifest ernaar wijst; "
|
||||||
|
"de image kopieert het, zodat er niet twee exemplaren zijn")
|
||||||
|
u.check("de agent gaat naar /app/agent.py",
|
||||||
|
kopieert("tools/electrum-gate/agent.py", "/app/agent.py"),
|
||||||
|
"de compose start hem op dat pad")
|
||||||
|
u.check("entrypoint.sh gaat mee en wordt uitvoerbaar",
|
||||||
|
kopieert("tools/electrum-gate/entrypoint.sh", "/entrypoint.sh")
|
||||||
|
and any(r.startswith("RUN chmod") and "/entrypoint.sh" in r for r in regels),
|
||||||
|
"git op Windows kent geen uitvoerbit, dus die moet de Dockerfile zetten")
|
||||||
|
u.check("python3 wordt geïnstalleerd",
|
||||||
|
any(r.startswith("RUN apk add") and "python3" in r for r in regels))
|
||||||
|
u.check("de basisimage is nginx op een vaste tag",
|
||||||
|
any(re.match(r"FROM nginx:\d+\.\d+-alpine$", r) for r in regels),
|
||||||
|
"nginx:alpine beweegt mee en is ook mét digest niet toegestaan; "
|
||||||
|
"zie HomeGit/Docs/Referenties/UmbrelApps/Images-pinnen.md")
|
||||||
|
u.check("het entrypoint van de nginx-image is uitgeschakeld",
|
||||||
|
"ENTRYPOINT []" in regels,
|
||||||
|
"anders draait het envsubst-script van die image mee, en dat is "
|
||||||
|
"precies het soort stille invulling dat dit project vreest")
|
||||||
|
|
||||||
|
# De compose noemt paden in de image; die moeten er zijn.
|
||||||
|
compose = zonder_commentaar(lees(COMPOSE))
|
||||||
|
u.check("de compose start de agent op het pad waar de Dockerfile hem neerzet",
|
||||||
|
any(r == "- /app/agent.py" for r in compose))
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_en_recept_lopen_gelijk(u):
|
||||||
|
"""Dezelfde image in beide services, met de tag uit build.sh.
|
||||||
|
|
||||||
|
Twee services die elk hun eigen tag hebben lopen uit elkaar bij de eerste
|
||||||
|
verhoging die iemand op één plek doet. En een tag die niet gelijk is aan
|
||||||
|
VERSION in build.sh betekent een image die nooit gebouwd is.
|
||||||
|
"""
|
||||||
|
compose = zonder_commentaar(lees(COMPOSE))
|
||||||
|
images = [r.split(":", 1)[1].strip() for r in compose if r.startswith("image:")]
|
||||||
|
u.check("de compose heeft twee images", len(images) == 2, "gevonden: %r" % images)
|
||||||
|
|
||||||
|
tags = set()
|
||||||
|
for image in images:
|
||||||
|
zonder_digest = image.split("@", 1)[0]
|
||||||
|
tags.add(zonder_digest)
|
||||||
|
u.check("en het is twee keer dezelfde", len(tags) == 1, "gevonden: %r" % sorted(tags))
|
||||||
|
|
||||||
|
versie = None
|
||||||
|
for regel in zonder_commentaar(lees(BUILD)):
|
||||||
|
if regel.startswith("VERSION="):
|
||||||
|
versie = regel.split("=", 1)[1].strip().strip('"')
|
||||||
|
u.check("build.sh heeft een VERSION", bool(versie))
|
||||||
|
if versie and tags:
|
||||||
|
tag = sorted(tags)[0]
|
||||||
|
u.check("de tag in de compose is VERSION uit build.sh",
|
||||||
|
tag.endswith(":" + versie),
|
||||||
|
"compose %r, build.sh %r" % (tag, versie))
|
||||||
|
|
||||||
|
# Wat er niet meer gemount mag worden: de programmabestanden. Zit er weer
|
||||||
|
# een mount van een los bestand uit de app-map in, dan is dat een terugval
|
||||||
|
# naar de template-tijd en bereikt een wijziging het apparaat via twee wegen.
|
||||||
|
losse = [r for r in compose if r.startswith("- ${APP_DATA_DIR}/")
|
||||||
|
and "/" not in r[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]]
|
||||||
|
u.check("geen losse bestanden uit de app-map meer gemount",
|
||||||
|
not losse, "gevonden: %r" % losse)
|
||||||
|
|
||||||
|
u.check("de server krijgt het backend-adres uit de omgeving",
|
||||||
|
compose.count("GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}") == 2
|
||||||
|
and compose.count("GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}") == 2,
|
||||||
|
"agent én server hebben het nodig; entrypoint.sh vult er het "
|
||||||
|
"stream-blok mee in")
|
||||||
|
|
||||||
|
u.check("de agent krijgt de versie van het manifest door",
|
||||||
|
"GATE_APP_VERSION: ${APP_VERSION}" in compose,
|
||||||
|
"anders toont de kop van de pagina geen versie")
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploadpad(u):
|
||||||
|
"""De drie dingen buiten de agent die uploaden mogelijk maken.
|
||||||
|
|
||||||
|
Alle drie kunnen ze stil wegvallen: een mount die weer `:ro` wordt geeft een
|
||||||
|
foutmelding pas als iemand iets uploadt, en een te kleine limiet in nginx
|
||||||
|
geeft een 413 die niets uitlegt.
|
||||||
|
"""
|
||||||
|
compose = zonder_commentaar(lees(COMPOSE))
|
||||||
|
nginx = zonder_commentaar(lees(NGINX_CONF))
|
||||||
|
|
||||||
|
eigen = [r for r in compose if "/certs/own" in r and r.startswith("-")]
|
||||||
|
u.check("de eigen certificaatmap is twee keer gemount",
|
||||||
|
len(eigen) == 2, "gevonden: %r" % eigen)
|
||||||
|
u.check("en precies één daarvan is beschrijfbaar",
|
||||||
|
len([r for r in eigen if not r.endswith(":ro")]) == 1,
|
||||||
|
"zonder :ro kan de agent niets neerzetten, met :ro overal ook niet: %r"
|
||||||
|
% eigen)
|
||||||
|
|
||||||
|
u.check("GATE_UPLOAD_DIR staat in de omgeving van de agent",
|
||||||
|
any(r.startswith("GATE_UPLOAD_DIR:") for r in compose),
|
||||||
|
"leeg of afwezig betekent: uploaden staat uit")
|
||||||
|
|
||||||
|
u.check("nginx heeft een eigen locatie voor de upload",
|
||||||
|
any("location = /api/certificate/upload" in r for r in nginx))
|
||||||
|
# De limiet moet groter zijn dan die van /api/, anders kapt nginx het verzoek
|
||||||
|
# af voordat de agent er iets over kan zeggen.
|
||||||
|
limieten = []
|
||||||
|
for regel in nginx:
|
||||||
|
if regel.startswith("client_max_body_size"):
|
||||||
|
limieten.append(regel.rstrip(";").split()[-1])
|
||||||
|
u.check("en een grotere limiet dan het gewone api-pad",
|
||||||
|
len(limieten) == 2 and limieten[0] != limieten[1],
|
||||||
|
"gevonden limieten: %r" % limieten)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbindingsteller(u):
|
||||||
|
"""De teller in de nginx-container, en de poort in hex.
|
||||||
|
|
||||||
|
Het gevaarlijke deel is de hex: de teller zoekt in /proc/net/tcp op de poort
|
||||||
|
in hexadecimale vorm, en verandert de TLS-poort ooit, dan telt hij stil nul.
|
||||||
|
Een teller die altijd nul zegt, ziet eruit als "geen wallet verbonden" en
|
||||||
|
niet als een fout.
|
||||||
|
"""
|
||||||
|
regels = zonder_commentaar(lees(ENTRYPOINT))
|
||||||
|
|
||||||
|
teller = [r for r in regels if "/proc/net/tcp" in r]
|
||||||
|
u.check("de teller leest /proc/net/tcp", bool(teller),
|
||||||
|
"dat is de enige plek waar de open verbindingen van deze container "
|
||||||
|
"staan; de agent zit in een andere netwerk-namespace")
|
||||||
|
|
||||||
|
u.check("en schrijft het aantal weg voor de agent",
|
||||||
|
any("/sessions" in r for r in regels))
|
||||||
|
|
||||||
|
# De poort uit de omgeving van de agent is de waarheid; de teller moet
|
||||||
|
# dezelfde poort in hex zoeken.
|
||||||
|
compose = zonder_commentaar(lees(COMPOSE))
|
||||||
|
poorten = [r for r in compose if r.startswith("GATE_TLS_PORT:")]
|
||||||
|
u.check("GATE_TLS_PORT staat in de compose", len(poorten) == 1,
|
||||||
|
"gevonden: %r" % poorten)
|
||||||
|
if not poorten or not teller:
|
||||||
|
return
|
||||||
|
|
||||||
|
poort = int(poorten[0].split(":", 1)[1].strip().strip('"'))
|
||||||
|
hexpoort = "%X" % poort
|
||||||
|
# Over de regels heen zoeken, want de awk-regel staat achter een
|
||||||
|
# backslash-vervolg en dan valt de hex op een eigen regel.
|
||||||
|
u.check("de teller zoekt de TLS-poort in hex (%d is %s)" % (poort, hexpoort),
|
||||||
|
(":" + hexpoort) in " ".join(regels),
|
||||||
|
"nergens in entrypoint.sh staat :%s, dus telt de teller nul"
|
||||||
|
% hexpoort)
|
||||||
|
|
||||||
|
stream = zonder_commentaar(lees(STREAM_CONF))
|
||||||
|
u.check("en stream.conf luistert op diezelfde poort",
|
||||||
|
any(r == "listen %d ssl;" % poort for r in stream))
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_onder_data(u):
|
||||||
|
"""Alles wat de app zelf schrijft staat onder `data/`.
|
||||||
|
|
||||||
|
De conventie van de officiele appstore, afgelezen aan echte apps: electrs
|
||||||
|
mount `${APP_DATA_DIR}/data/electrs`, mempool `${APP_DATA_DIR}/data`. Deze app
|
||||||
|
had `runtime/` en `certs/` naast de templates staan. Verplaatst op 20-08-2026
|
||||||
|
met het oog op publicatie.
|
||||||
|
|
||||||
|
Waarom er een toets op staat: bij de volgende mount die iemand toevoegt is dit
|
||||||
|
precies het detail dat je vergeet, en niets gaat er stuk van. Het valt pas op
|
||||||
|
bij het inleveren.
|
||||||
|
"""
|
||||||
|
regels = zonder_commentaar(lees(COMPOSE))
|
||||||
|
mounts = [r[1:].strip() for r in regels
|
||||||
|
if r.startswith("-") and "${APP_DATA_DIR}" in r]
|
||||||
|
u.check("er zijn mounts uit de app-datamap", bool(mounts))
|
||||||
|
|
||||||
|
fout = []
|
||||||
|
for mount in mounts:
|
||||||
|
host = mount.strip('"').split(":")[0].replace("${APP_DATA_DIR}/", "")
|
||||||
|
if not host.startswith("data/"):
|
||||||
|
fout.append(host)
|
||||||
|
u.check("elke mount uit de app-map staat onder data/",
|
||||||
|
not fout, "deze niet: %r" % fout)
|
||||||
|
|
||||||
|
u.check("de gedeelde toestand staat onder data/",
|
||||||
|
any("${APP_DATA_DIR}/data/runtime:" in m for m in mounts))
|
||||||
|
u.check("de eigen certificaatmap staat onder data/",
|
||||||
|
any("${APP_DATA_DIR}/data/certs:" in m for m in mounts))
|
||||||
|
|
||||||
|
# En het manifest moet die paden ook noemen, anders wijst backupIgnore naar
|
||||||
|
# iets wat niet bestaat en gaat de sessielog alsnog mee in elke back-up.
|
||||||
|
manifest = lees(MANIFEST)
|
||||||
|
u.check("backupIgnore staat in het manifest", "backupIgnore:" in manifest)
|
||||||
|
for pad in ("data/runtime/stream.log", "data/runtime/status.json"):
|
||||||
|
u.check("backupIgnore noemt %s" % pad, pad in manifest)
|
||||||
|
|
||||||
|
|
||||||
|
# De volgorde die de packaging-documentatie van de officiele appstore voorschrijft.
|
||||||
|
# Wat de spec niet noemt, zoals icon en backupIgnore, hoort daarachter en niet
|
||||||
|
# ertussen: dan is de kop van het manifest letterlijk goed.
|
||||||
|
MANIFEST_ORDE = [
|
||||||
|
"manifestVersion", "id", "category", "name", "version", "tagline",
|
||||||
|
"description", "releaseNotes", "developer", "website", "dependencies",
|
||||||
|
"repo", "support", "port", "gallery", "path",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_velden():
|
||||||
|
"""De veldnamen op het hoogste niveau, in de volgorde van het bestand."""
|
||||||
|
velden = []
|
||||||
|
for regel in lees(MANIFEST).splitlines():
|
||||||
|
if not regel or regel[0] in " #-":
|
||||||
|
continue
|
||||||
|
if ":" in regel:
|
||||||
|
velden.append(regel.split(":", 1)[0])
|
||||||
|
return velden
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_volgorde(u):
|
||||||
|
"""De velden staan in de voorgeschreven volgorde.
|
||||||
|
|
||||||
|
Geen smaak: de officiele appstore schrijft deze volgorde voor. Een toets
|
||||||
|
hierop omdat een nieuw veld standaard onderaan of middenin belandt, en er
|
||||||
|
niets van stukgaat; het valt pas op bij het inleveren.
|
||||||
|
"""
|
||||||
|
velden = manifest_velden()
|
||||||
|
kern = [v for v in velden if v in MANIFEST_ORDE]
|
||||||
|
|
||||||
|
ontbreekt = [v for v in MANIFEST_ORDE if v not in velden]
|
||||||
|
u.check("alle voorgeschreven velden staan in het manifest",
|
||||||
|
not ontbreekt, "mist: %r" % ontbreekt)
|
||||||
|
u.check("en in de voorgeschreven volgorde",
|
||||||
|
kern == MANIFEST_ORDE, "de volgorde is nu: %r" % kern)
|
||||||
|
|
||||||
|
# De extra's horen achter de voorgeschreven reeks. Zo is de kop van het
|
||||||
|
# bestand letterlijk op orde en hoeft er bij inlevering alleen iets weg.
|
||||||
|
if kern == MANIFEST_ORDE and velden:
|
||||||
|
laatste_kern = velden.index(MANIFEST_ORDE[-1])
|
||||||
|
extra_ervoor = [v for v in velden[:laatste_kern] if v not in MANIFEST_ORDE]
|
||||||
|
u.check("en wat de spec niet noemt staat erachter",
|
||||||
|
not extra_ervoor, "deze staan ertussen: %r" % extra_ervoor)
|
||||||
|
|
||||||
|
u.check("de lege standaard-inloggegevens zijn eruit",
|
||||||
|
"defaultUsername" not in velden and "defaultPassword" not in velden,
|
||||||
|
"deze app heeft geen eigen inlog, dus een leeg veld zegt niets")
|
||||||
|
|
||||||
|
|
||||||
|
def test_releasenotes_hebben_een_geschiedenis(u):
|
||||||
|
"""Eén verhaal over deze versie, dan één regel per eerdere versie.
|
||||||
|
|
||||||
|
Deze toets bestaat om een echte fout: drie versies achter elkaar kwam er een
|
||||||
|
nieuwe kop bovenop de release notes terwijl de oude tekst eronder bleef staan.
|
||||||
|
In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer dezelfde
|
||||||
|
regel over 0.0.4. Dat is zichtbare tekst in de appstore, en niemand leest zijn
|
||||||
|
eigen release notes nog een keer na.
|
||||||
|
"""
|
||||||
|
tekst = lees(MANIFEST)
|
||||||
|
u.check("er staat precies een keer een geschiedenis-kop",
|
||||||
|
tekst.count("Earlier releases:") == 1,
|
||||||
|
"gevonden: %d keer" % tekst.count("Earlier releases:"))
|
||||||
|
|
||||||
|
# Elke eerdere versie mag maar een keer genoemd worden.
|
||||||
|
dubbel = []
|
||||||
|
for versie in ("0.0.4", "0.0.5", "0.0.6", "0.0.7", "0.0.8"):
|
||||||
|
aantal = tekst.count("\n " + versie + " ")
|
||||||
|
if aantal > 1:
|
||||||
|
dubbel.append((versie, aantal))
|
||||||
|
u.check("en geen versie wordt twee keer beschreven",
|
||||||
|
not dubbel, "dubbel: %r" % dubbel)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tagline_is_overal_dezelfde(u):
|
||||||
|
"""De regel onder de app-naam staat op twee plekken en moet gelijk zijn.
|
||||||
|
|
||||||
|
Besloten door de gebruiker op 20-08-2026, nadat hij zag dat de winkel en de
|
||||||
|
pagina iets anders zeiden. Eén app, één regel. Deze toets bestaat omdat de
|
||||||
|
twee plekken ver uit elkaar staan: niemand die de tagline in het manifest
|
||||||
|
aanpast, opent daarna de pagina.
|
||||||
|
"""
|
||||||
|
tagline = None
|
||||||
|
for regel in lees(MANIFEST).splitlines():
|
||||||
|
if regel.startswith("tagline:"):
|
||||||
|
tagline = regel.split(":", 1)[1].strip().strip('"')
|
||||||
|
break
|
||||||
|
|
||||||
|
u.check("het manifest heeft een tagline", bool(tagline))
|
||||||
|
if not tagline:
|
||||||
|
return
|
||||||
|
|
||||||
|
u.check("en de pagina gebruikt woordelijk dezelfde regel",
|
||||||
|
tagline in lees(PAGINA),
|
||||||
|
"de pagina noemt hem niet: %r" % tagline)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_zonder_invulling(u):
|
||||||
|
"""De pagina zit in de image en wordt door niemand meer ingevuld.
|
||||||
|
|
||||||
|
Een ${...} die blijft staan komt letterlijk in de browser terecht. En de twee
|
||||||
|
waarden die umbreld vroeger invulde, de versie en het backend-adres, moeten
|
||||||
|
nu uit status.json komen; anders is de kop leeg en het adres ook.
|
||||||
|
"""
|
||||||
|
tekst = lees(PAGINA)
|
||||||
|
u.check("de pagina bevat geen accolade-variabele", "${" not in tekst)
|
||||||
|
u.check("de kop haalt de versie uit status.json",
|
||||||
|
"s.version" in tekst and 'id="head-version"' in tekst)
|
||||||
|
u.check("het backend-adres komt uit status.json",
|
||||||
|
"backend.address" in tekst)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
u = Uitslag()
|
||||||
|
test_pagina_hangt_niet_aan_een_certificaat(u)
|
||||||
|
test_stream_blok_staat_apart(u)
|
||||||
|
test_entrypoint_wacht_niet_op_de_agent(u)
|
||||||
|
test_dockerfile_neemt_alles_mee(u)
|
||||||
|
test_compose_en_recept_lopen_gelijk(u)
|
||||||
|
test_verbindingsteller(u)
|
||||||
|
test_uploadpad(u)
|
||||||
|
test_data_onder_data(u)
|
||||||
|
test_manifest_volgorde(u)
|
||||||
|
test_releasenotes_hebben_een_geschiedenis(u)
|
||||||
|
test_tagline_is_overal_dezelfde(u)
|
||||||
|
test_pagina_zonder_invulling(u)
|
||||||
|
return u.rapport()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# De image voor de app whatsnext-electrum-gate.
|
||||||
|
#
|
||||||
|
# Eén image voor twee containers. De compose start hem twee keer: als `server`
|
||||||
|
# met het standaardcommando (entrypoint.sh, dat nginx wordt) en als `agent` met
|
||||||
|
# `python3 /app/agent.py`. Twee images was netter gescheiden geweest, maar het
|
||||||
|
# verdubbelt het bouw- en pinwerk, en nginx plus python3 in één alpine is klein.
|
||||||
|
# Beslist bij de start van het plan Eigenimage, OPEN.md punt 2.
|
||||||
|
#
|
||||||
|
# Tot 0.0.29 stonden deze bestanden als *.template in de app-map en draaiden ze
|
||||||
|
# op python:3-alpine en nginx:alpine. Wat dat kostte staat in het plan
|
||||||
|
# Eigenimage, PLAN.md §3; het kortste antwoord: het command-blok van de compose
|
||||||
|
# was ruim honderd regels shell in een YAML-string met verdubbelde dollartekens,
|
||||||
|
# en dat is nu een gewoon script.
|
||||||
|
#
|
||||||
|
# De bouwcontext is de REPO-ROOT, niet deze map. Dat is omdat icon.png in de
|
||||||
|
# app-map blijft staan (het manifest wijst ernaar voor de tegel in de winkel) en
|
||||||
|
# de pagina hem óók nodig heeft; kopiëren uit de app-map voorkomt een tweede
|
||||||
|
# exemplaar dat stil achterloopt. build.sh geeft die context mee.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# nginx en niet python als basis: de officiële nginx-image heeft de stream-module
|
||||||
|
# ingebouwd, en op alpine's eigen nginx-pakket is dat een los pakket. De app draaide
|
||||||
|
# al op nginx:alpine, dus dit houdt nginx precies zoals hij was. De tag is de
|
||||||
|
# stable-reeks (even tweede cijfer); zie HomeGit/Docs/Referenties/UmbrelApps/Images-pinnen.md.
|
||||||
|
FROM nginx:1.30-alpine
|
||||||
|
|
||||||
|
# De agent gebruikt alleen de standaardbibliotheek, inclusief ssl; dat zit in het
|
||||||
|
# python3-pakket van alpine.
|
||||||
|
RUN apk add --no-cache python3
|
||||||
|
|
||||||
|
COPY tools/electrum-gate/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
# Met .in erachter, want dit is nog niet de configuratie die nginx leest: het
|
||||||
|
# backend-adres komt uit de omgeving en entrypoint.sh vult het bij het starten in.
|
||||||
|
COPY tools/electrum-gate/stream.conf /etc/nginx/stream.conf.in
|
||||||
|
COPY tools/electrum-gate/index.html /usr/share/nginx/html/index.html
|
||||||
|
COPY whatsnext-electrum-gate/icon.png /usr/share/nginx/html/icon.png
|
||||||
|
COPY tools/electrum-gate/agent.py /app/agent.py
|
||||||
|
COPY tools/electrum-gate/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
# De uitvoerbit hier zetten en niet vertrouwen op wat git meegeeft: de repo wordt
|
||||||
|
# ook op Windows bewerkt en daar bestaat die bit niet.
|
||||||
|
RUN chmod 0755 /entrypoint.sh
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 80 50022
|
||||||
|
|
||||||
|
# De nginx-image heeft een eigen entrypoint dat scripts uit /docker-entrypoint.d
|
||||||
|
# draait. Die doen hier niets nuttigs (envsubst op een templates-map die leeg is)
|
||||||
|
# en één ervan zou, als hij ooit wél iets deed, precies de fout maken waar dit
|
||||||
|
# project al een dag aan kwijt was. Weg ermee: het commando is het commando.
|
||||||
|
ENTRYPOINT []
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Bouwt de image voor de app whatsnext-electrum-gate.
|
||||||
|
#
|
||||||
|
# Eén image voor de twee containers van de app: nginx met de pagina en het
|
||||||
|
# stream-blok, en python3 met de agent. Wat erin gaat staat in de Dockerfile
|
||||||
|
# hiernaast; de bestanden zelf staan in deze map, behalve icon.png, dat in de
|
||||||
|
# app-map blijft omdat het manifest ernaar wijst.
|
||||||
|
#
|
||||||
|
# Waarom dit NIET in de app-map staat: umbreld kopieert de hele app-map naar het
|
||||||
|
# apparaat, en bij een update wordt alleen een whitelist ververst waar een
|
||||||
|
# Dockerfile niet in zit. Bouwen tijdens het starten van de app zou de installatie
|
||||||
|
# bovendien minuten laten hangen. Het recept hoort dus in de repo en het resultaat
|
||||||
|
# in een register; de app verwijst alleen naar de tag.
|
||||||
|
#
|
||||||
|
# Draaien: sh tools/electrum-gate/build.sh
|
||||||
|
# Vereist: docker op de machine waar je bouwt. Op de Umbrel is dat met sudo.
|
||||||
|
#
|
||||||
|
# Multi-arch: PLATFORMS="linux/amd64,linux/arm64" sh tools/electrum-gate/build.sh
|
||||||
|
# Dat vraagt buildx met QEMU en duwt meteen naar het register, want een
|
||||||
|
# multi-arch-manifest bestaat niet lokaal. Nog nooit geprobeerd; zie het plan
|
||||||
|
# Eigenimage, PLAN.md §4. Zonder PLATFORMS bouwt dit één architectuur, die van
|
||||||
|
# de machine waarop je staat, en dat is voor eigen gebruik op een amd64-Umbrel
|
||||||
|
# genoeg.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# ── De pin ────────────────────────────────────────────────────────────────────
|
||||||
|
# Dit is het etiket op de IMAGE. Verhoog het als de image verandert, dus als er
|
||||||
|
# iets in deze map of in icon.png wijzigt, en zet dan dezelfde waarde achter
|
||||||
|
# `image:` in de compose (twee keer: agent en server delen hem).
|
||||||
|
#
|
||||||
|
# `version` in umbrel-app.yml is een ander nummer en mag hierop vooruitlopen: dat
|
||||||
|
# moet bij élke wijziging aan de app omhoog, ook als alleen de compose verandert,
|
||||||
|
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
|
||||||
|
# Sinds de eigen image raakt vrijwel elke wijziging de image, dus ze lopen
|
||||||
|
# meestal gelijk; de regel blijft: verhoog je VERSION, dan ook het manifest.
|
||||||
|
VERSION="0.1.0"
|
||||||
|
|
||||||
|
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
|
||||||
|
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
|
||||||
|
# hem onbereikbaar en de installatie faalt met "pull access denied". Dat is op
|
||||||
|
# 25-08-2026 bij Evolu Relay op het apparaat vastgesteld.
|
||||||
|
IMAGE="sc.kamenier-hamer.nl/sysop/electrum-gate:${VERSION}"
|
||||||
|
|
||||||
|
RECEPT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
# De bouwcontext is de repo-root, want icon.png komt uit de app-map. Zie de kop
|
||||||
|
# van de Dockerfile.
|
||||||
|
REPO="$(CDPATH= cd -- "${RECEPT}/../.." && pwd)"
|
||||||
|
|
||||||
|
# ── Controles vooraf ──────────────────────────────────────────────────────────
|
||||||
|
# Beter hier hard falen dan een image bouwen die iets anders bevat dan je denkt.
|
||||||
|
|
||||||
|
for bestand in Dockerfile entrypoint.sh agent.py nginx.conf stream.conf index.html; do
|
||||||
|
if [ ! -f "${RECEPT}/${bestand}" ]; then
|
||||||
|
echo "FOUT: ${bestand} ontbreekt in ${RECEPT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "${REPO}/whatsnext-electrum-gate/icon.png" ]; then
|
||||||
|
echo "FOUT: whatsnext-electrum-gate/icon.png ontbreekt in ${REPO}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Een accolade-variabele hoort hier nergens meer in: deze bestanden gaan niet
|
||||||
|
# meer door envsubst, dus wat er staat is wat er draait. Staat er tóch een, dan
|
||||||
|
# is dat een restant van de template-tijd en zou hij letterlijk in de pagina of
|
||||||
|
# de configuratie terechtkomen.
|
||||||
|
if grep -l '\${' "${RECEPT}/nginx.conf" "${RECEPT}/stream.conf" "${RECEPT}/index.html" 2>/dev/null; then
|
||||||
|
echo "FOUT: de bestanden hierboven bevatten nog een \${...}; die wordt niet meer ingevuld." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Bouwen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PLATFORMS="${PLATFORMS:-}"
|
||||||
|
|
||||||
|
if [ -n "$PLATFORMS" ]; then
|
||||||
|
echo "Bouwen als ${IMAGE} voor ${PLATFORMS}, en meteen duwen"
|
||||||
|
docker buildx build --platform "$PLATFORMS" --tag "$IMAGE" --push \
|
||||||
|
--file "${RECEPT}/Dockerfile" "$REPO"
|
||||||
|
echo
|
||||||
|
echo "Geduwd. De index-digest:"
|
||||||
|
docker buildx imagetools inspect "$IMAGE" | head -3
|
||||||
|
else
|
||||||
|
echo "Bouwen als ${IMAGE}"
|
||||||
|
docker build --tag "$IMAGE" --file "${RECEPT}/Dockerfile" "$REPO"
|
||||||
|
echo
|
||||||
|
echo "Klaar:"
|
||||||
|
docker image inspect --format '{{.RepoTags}} {{.Id}}' "$IMAGE"
|
||||||
|
echo
|
||||||
|
echo "Bouwen is niet genoeg: de app verwijst naar het register, want umbreld kan"
|
||||||
|
echo "niet bij een image die alleen lokaal staat. Nog te doen:"
|
||||||
|
echo
|
||||||
|
echo " docker login sc.kamenier-hamer.nl"
|
||||||
|
echo " docker push ${IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Zet daarna de digest uit de push-uitvoer achter de tag in"
|
||||||
|
echo "whatsnext-electrum-gate/docker-compose.yml (twee keer, agent en server), en"
|
||||||
|
echo "zet \`version\` in het manifest op ${VERSION}. Zonder die verhoging rolt"
|
||||||
|
echo "umbrelOS het niet uit."
|
||||||
|
echo
|
||||||
|
echo "Controleer dat het anoniem te halen is, want umbreld krijgt geen"
|
||||||
|
echo "inloggegevens mee. Log uit in dezelfde context waarin je inlogde, anders"
|
||||||
|
echo "meet je je eigen sessie:"
|
||||||
|
echo
|
||||||
|
echo " docker logout sc.kamenier-hamer.nl && docker pull ${IMAGE}"
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Het startscript van de server-container van Electrum Gate.
|
||||||
|
#
|
||||||
|
# Tot 0.0.29 stond dit als inline command-blok in docker-compose.yml, omdat de
|
||||||
|
# compose in de update-whitelist van umbreld staat en een los script niet. Dat
|
||||||
|
# betekende shell in een YAML-string, met elk dollarteken verdubbeld omdat
|
||||||
|
# umbreld anders elke ${...} leegmaakt. Sinds de eigen image is het een gewoon
|
||||||
|
# bestand; wat het doet is niet veranderd.
|
||||||
|
#
|
||||||
|
# Drie dingen die hier gebeuren en waarom ze hier gebeuren en niet in de agent:
|
||||||
|
#
|
||||||
|
# 1. de TLS-poort aan- of uitzetten naar de toestand van cert.conf. nginx weigert
|
||||||
|
# te starten met een 'listen ssl' zonder certificaat, en de pagina waarop je
|
||||||
|
# dat certificaat kiest moet juist altijd omhoog komen;
|
||||||
|
# 2. de open verbindingen tellen. Dat kan alleen in deze netwerk-namespace;
|
||||||
|
# 3. nginx herladen als de agent een vlag neerzet. De agent kan dat niet zelf,
|
||||||
|
# want dat zou de Docker-socket vragen en die is er bewust uit.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
STATE=/var/lib/gate
|
||||||
|
mkdir -p "$STATE" "$STATE/tls"
|
||||||
|
|
||||||
|
# ── Het stream-blok invullen ──────────────────────────────────────────────────
|
||||||
|
# Het backend-adres komt uit de omgeving, dezelfde twee variabelen die de agent
|
||||||
|
# krijgt. Tot 0.0.29 vulde umbreld ze in via stream.conf.template; nu de
|
||||||
|
# configuratie in de image zit, gebeurt dat hier. nginx kan in een proxy_pass van
|
||||||
|
# het stream-blok zelf geen omgevingsvariabele lezen.
|
||||||
|
#
|
||||||
|
# Twee plaatshouders zonder dollarteken, zodat het bestand zelf nooit door een
|
||||||
|
# envsubst-stap beschadigd kan worden, ook niet als iemand het ooit weer als
|
||||||
|
# template in de app-map zet.
|
||||||
|
if [ -n "${GATE_ELECTRUM_HOST:-}" ] && [ -n "${GATE_ELECTRUM_PORT:-}" ]; then
|
||||||
|
sed -e "s|__GATE_ELECTRUM_HOST__|${GATE_ELECTRUM_HOST}|g" \
|
||||||
|
-e "s|__GATE_ELECTRUM_PORT__|${GATE_ELECTRUM_PORT}|g" \
|
||||||
|
/etc/nginx/stream.conf.in > /etc/nginx/stream.conf
|
||||||
|
else
|
||||||
|
# Zonder backend is er niets om naar door te sturen. De pagina komt wel omhoog
|
||||||
|
# en de agent meldt daar dat het adres ontbreekt; TLS blijft uit.
|
||||||
|
rm -f /etc/nginx/stream.conf
|
||||||
|
echo "GATE_ELECTRUM_HOST of GATE_ELECTRUM_PORT ontbreekt; TLS blijft uit, de pagina werkt."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── De TLS-poort aan of uit ───────────────────────────────────────────────────
|
||||||
|
# Hier zat tot 0.0.3 een lus die wachtte tot de agent een certificaat gekozen had,
|
||||||
|
# en dat was fout: nginx startte dan niet, dus de pagina kwam niet omhoog, en de
|
||||||
|
# pagina is juist waar je dat certificaat kiest.
|
||||||
|
#
|
||||||
|
# Nu start nginx altijd. Het stream-blok komt erbij zodra cert.conf er is, en
|
||||||
|
# verdwijnt weer als de agent zijn keuze intrekt. nginx.conf haalt de map tls/ op
|
||||||
|
# met een jokerteken; die matcht dan niets en dat is geen fout.
|
||||||
|
#
|
||||||
|
# Deze functie eindigt altijd geslaagd, en dat is met opzet: het script draait
|
||||||
|
# onder 'set -e', dus een mislukte cp zou de aanroeper afbreken. In de lus
|
||||||
|
# hieronder is die aanroeper de wachtlus, en die mag om geen enkele reden stoppen.
|
||||||
|
sync_tls() {
|
||||||
|
if [ -f "$STATE/cert.conf" ] && [ -f /etc/nginx/stream.conf ]; then
|
||||||
|
# Kopiëren en niet linken: nginx opent dit pad als de gebruiker nginx, en een
|
||||||
|
# symlink is hier nodeloos fragiel.
|
||||||
|
if cp /etc/nginx/stream.conf "$STATE/tls/stream.conf"; then
|
||||||
|
echo "Certificaat aanwezig, TLS luistert op 50022."
|
||||||
|
else
|
||||||
|
echo "stream.conf kon niet worden weggezet; TLS blijft uit."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$STATE/tls/stream.conf"
|
||||||
|
echo "Nog geen certificaat gekozen; TLS staat uit, de pagina werkt."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
sync_tls
|
||||||
|
|
||||||
|
# ── De verbindingsteller ──────────────────────────────────────────────────────
|
||||||
|
# nginx stream schrijft zijn logregel pas bij het sluiten van een sessie, en een
|
||||||
|
# wallet houdt zijn verbinding uren open; zonder deze teller lijkt een actieve
|
||||||
|
# wallet dus afwezig. Dat was de vraag van de gebruiker op 20-08-2026.
|
||||||
|
#
|
||||||
|
# /proc/net/tcp geldt per netwerk-namespace, dus dit ziet alleen de sockets van
|
||||||
|
# deze container, en daarom staat de teller hier en niet in de agent: die zit in
|
||||||
|
# een andere namespace en kan er niet bij. Kolom 2 is het lokale adres met de
|
||||||
|
# poort in hex, kolom 4 de toestand. C366 is 50022 en 01 is ESTABLISHED, dus de
|
||||||
|
# luisterende socket (0A) en de verbindingen naar de backend en naar de pagina
|
||||||
|
# vallen er buiten.
|
||||||
|
#
|
||||||
|
# Geteld wordt alleen het aantal. Geen adres en geen bronpoort, net als in het
|
||||||
|
# log_format in stream.conf.
|
||||||
|
count_sessions() {
|
||||||
|
cat /proc/net/tcp /proc/net/tcp6 2>/dev/null \
|
||||||
|
| awk '$4 == "01" && $2 ~ /:C366$/ { n++ } END { print n+0 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Meteen een 0 neerzetten, zodat de pagina "geen verbindingen" kan tonen in
|
||||||
|
# plaats van "onbekend" in het minuutje voor de eerste ronde.
|
||||||
|
count_sessions > "$STATE/sessions"
|
||||||
|
|
||||||
|
# ── De herlaadlus ─────────────────────────────────────────────────────────────
|
||||||
|
# Draait op de achtergrond; nginx wordt hieronder met exec het hoofdproces.
|
||||||
|
# Andersom gaat twee keer mis: de shell blijft dan PID 1 en geeft signalen niet
|
||||||
|
# door, en als nginx omvalt eindigt het script met exitcode 0, waardoor Docker een
|
||||||
|
# geslaagde afsluiting ziet en 'restart: on-failure' niet ingrijpt.
|
||||||
|
reload_nginx() {
|
||||||
|
# Een certificaat dat nginx niet aanneemt laat de reload falen. Dat mag niet
|
||||||
|
# met 'set -e' meegaan, want dan verdwijnt de lus en wordt geen enkele latere
|
||||||
|
# wijziging meer opgepikt, zonder dat er iets te zien is.
|
||||||
|
if ! nginx -s reload; then
|
||||||
|
echo "Herladen mislukt; nginx houdt de vorige configuratie."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Eerst schrijven, dan mv: de agent leest dit bestand op zijn eigen moment en
|
||||||
|
# mag geen half bestand zien.
|
||||||
|
count_sessions > "$STATE/sessions.tmp"
|
||||||
|
mv "$STATE/sessions.tmp" "$STATE/sessions"
|
||||||
|
|
||||||
|
if [ -f "$STATE/reload" ]; then
|
||||||
|
echo "Certificaat gewijzigd, nginx herladen ..."
|
||||||
|
# Eerst wegzetten, dan herladen. Andersom zou een herlading die mislukt de
|
||||||
|
# vlag toch opruimen, en dan probeert hij het nooit meer.
|
||||||
|
mv "$STATE/reload" "$STATE/reload.done"
|
||||||
|
# Voor de herlading, niet erna: een reload leest de configuratie opnieuw,
|
||||||
|
# dus het stream-blok moet er dan al liggen.
|
||||||
|
sync_tls
|
||||||
|
reload_nginx
|
||||||
|
elif [ -f "$STATE/cert.conf" ] && [ ! -f "$STATE/tls/stream.conf" ]; then
|
||||||
|
# De vangnetregel. De agent zet de vlag bij elke wijziging, dus normaal komt
|
||||||
|
# hier niets langs; wel als de vlag verloren gaat of als cert.conf van een
|
||||||
|
# vorige installatie al klaarlag. Zonder deze tak zou TLS dan uit blijven
|
||||||
|
# staan tot de volgende wijziging.
|
||||||
|
echo "Certificaat gevonden zonder herlaadvlag, TLS aanzetten ..."
|
||||||
|
sync_tls
|
||||||
|
reload_nginx
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
|||||||
|
# nginx-configuratie voor Electrum Gate.
|
||||||
|
#
|
||||||
|
# Dit bestand zit in de image (tools/electrum-gate/Dockerfile) als
|
||||||
|
# /etc/nginx/nginx.conf. Tot 0.0.29 was het een *.template in de app-map die
|
||||||
|
# umbreld bij elke start door envsubst haalde, en daarom mocht er toen geen
|
||||||
|
# nginx-variabele met een dollarteken in staan. Die beperking is weg. Dat de
|
||||||
|
# access_log voor http nog steeds uit staat is een keuze en geen restant: de
|
||||||
|
# pagina hangt achter de app_proxy en wat die opvraagt is niet interessant.
|
||||||
|
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── De web-UI ────────────────────────────────────────────────────────────────
|
||||||
|
# Wordt niet rechtstreeks gepubliceerd: umbrelOS zet er zijn eigen app_proxy
|
||||||
|
# voor, en die verwijst naar poort 80 van deze container.
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
access_log off;
|
||||||
|
sendfile on;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# status.json wordt door de agent in de gedeelde map geschreven, niet in
|
||||||
|
# de webroot. Geen cache: de pagina vraagt hem juist op om te zien hoe
|
||||||
|
# oud de gegevens zijn.
|
||||||
|
location = /status.json {
|
||||||
|
alias /var/lib/gate/status.json;
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
# De API van de agent. Het enige pad waarlangs iets van buiten de
|
||||||
|
# instellingen van de app raakt, en dat is de certificaatkeuze.
|
||||||
|
#
|
||||||
|
# Waarom dit te verantwoorden is op een app waarvan de kleinheid het punt
|
||||||
|
# is: het pad hangt achter de app_proxy van umbrelOS, die er zijn eigen
|
||||||
|
# inlog voor zet, de TLS-poort staat er los van, en de agent accepteert
|
||||||
|
# alleen een certificaat-id die hij zelf in de gemounte mappen gevonden
|
||||||
|
# heeft. Het ergste wat een geslaagde aanroep doet is een ander, ook
|
||||||
|
# bestaand, certificaat kiezen.
|
||||||
|
#
|
||||||
|
# Geen proxy_set_header hier: de agent heeft ze niet nodig.
|
||||||
|
#
|
||||||
|
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
|
||||||
|
# tot 0.0.23 wel, en het werkte zolang deze app de enige was met een
|
||||||
|
# service van die naam. Sinds Evolu Relay er is, is dat niet meer waar:
|
||||||
|
# beide apps zitten op hetzelfde Docker-netwerk, allebei met een 'agent'
|
||||||
|
# op poort 8000, en Docker verdeelt de naam dan over de twee. Op
|
||||||
|
# 28-08-2026 gemeten op het apparaat: van tien verzoeken kwamen er vijf
|
||||||
|
# bij de verkeerde app uit. De vorm <app-id>_<service>_1 is uniek per app;
|
||||||
|
# het is dezelfde les die in de compose al stond over de naam 'server'.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://whatsnext-electrum-gate_agent_1:8000;
|
||||||
|
client_max_body_size 1k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Het uploaden van een certificaat, en het enige pad met een grotere
|
||||||
|
# limiet: een sleutel plus keten haalt de 1k van hierboven ruim. Een
|
||||||
|
# exacte match, dus deze regel wint van /api/ hierboven en de grotere
|
||||||
|
# limiet geldt nergens anders. De agent kapt zelf ook af, op 96k; deze
|
||||||
|
# limiet is de eerste zeef en niet de enige.
|
||||||
|
location = /api/certificate/upload {
|
||||||
|
proxy_pass http://whatsnext-electrum-gate_agent_1:8000;
|
||||||
|
client_max_body_size 96k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── De TLS-terminatie ────────────────────────────────────────────────────────
|
||||||
|
# Die staat niet hier maar in stream.conf, en dat is geen ordening maar de
|
||||||
|
# reparatie van een klem. nginx weigert te starten als een 'listen ... ssl' geen
|
||||||
|
# certificaat heeft; stond het stream-blok hier, dan kwam met dat blok ook de
|
||||||
|
# pagina hierboven niet omhoog zolang de agent geen certificaat gekozen had. En
|
||||||
|
# de pagina is juist de plek waar die keuze gemaakt wordt.
|
||||||
|
#
|
||||||
|
# entrypoint.sh legt stream.conf in de map hieronder zodra cert.conf bestaat, en
|
||||||
|
# haalt het weg als dat niet zo is. Een include met een jokerteken die nergens op
|
||||||
|
# uitkomt is voor nginx geen fout, dus zonder certificaat start deze server
|
||||||
|
# zonder poort 50022 en met een pagina die vertelt waarom.
|
||||||
|
include /var/lib/gate/tls/*.conf;
|
||||||
@@ -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__;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# De image voor de app whatsnext-evolu-relay.
|
||||||
|
#
|
||||||
|
# Eén image voor drie containers. De compose start hem drie keer: als `relay`
|
||||||
|
# met het standaardcommando (`node src/index.js`, als gebruiker node), als
|
||||||
|
# `agent` met `python3 /app/agent.py` en als `server` met nginx. Tot 0.5.6 zat
|
||||||
|
# alleen de relay in deze image en draaiden de agent en de pagina als *.template
|
||||||
|
# op python:3-alpine en nginx:alpine; sinds 0.6.0 zit alles hier. Beslist door de
|
||||||
|
# gebruiker op 08-09-2026, na dezelfde stap bij Electrum Gate: één image scheelt
|
||||||
|
# een tweede recept en een tweede pin. Zie het plan Eigenimage, PLAN.md §8.
|
||||||
|
#
|
||||||
|
# Dit bouwt niet de relay van Evolu na: het installeert `@evolu/nodejs` uit npm
|
||||||
|
# en start ons eigen `src/index.js`, dat alleen de twee terugroepfuncties voor de
|
||||||
|
# toegangscontrole toevoegt.
|
||||||
|
#
|
||||||
|
# Waarom `slim` en niet `alpine`: `better-sqlite3` zit onder `@evolu/nodejs` en
|
||||||
|
# heeft kant-en-klare binaries voor glibc, niet voor musl. Op alpine zou hij bij
|
||||||
|
# elke bouw opnieuw gecompileerd moeten worden, met een bouwketen erbij in de
|
||||||
|
# image. Dat is ook de reden dat nginx en python3 hier uit apt komen en niet
|
||||||
|
# andersom node in een nginx-image gezet is.
|
||||||
|
#
|
||||||
|
# De bouwcontext is de REPO-ROOT, niet deze map: icon.png blijft in de app-map
|
||||||
|
# staan (het manifest wijst ernaar voor de tegel in de winkel) en wordt van daar
|
||||||
|
# gekopieerd, zodat er niet twee exemplaren zijn. build.sh geeft die context mee.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
FROM node:24-slim
|
||||||
|
|
||||||
|
# nginx en python3 uit Debian. Geen aanbevolen pakketten: die halen bij nginx
|
||||||
|
# onder meer een set modules binnen die de pagina niet gebruikt. De agent heeft
|
||||||
|
# alleen de standaardbibliotheek nodig.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nginx python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Eerst alleen het manifest, zodat een wijziging in `src/` de installatielaag niet
|
||||||
|
# ongeldig maakt.
|
||||||
|
COPY tools/evolu-relay/package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY tools/evolu-relay/src ./src
|
||||||
|
COPY tools/evolu-relay/agent.py ./agent.py
|
||||||
|
|
||||||
|
# De relay maakt zelf `data/` aan bij de start, maar dan als de gebruiker die op
|
||||||
|
# dat moment draait. Vooraf aanmaken met de juiste eigenaar voorkomt dat een
|
||||||
|
# gemounte map van de host als root wordt aangeraakt. Alleen `data/` en `src/` zijn
|
||||||
|
# van node; agent.py en de rest blijven van root, want die worden alleen gelezen.
|
||||||
|
RUN mkdir -p /app/data && chown -R node:node /app/data /app/src
|
||||||
|
|
||||||
|
# De pagina. Debian zet nginx' eigen voorbeeldpagina onder /var/www/html; wij
|
||||||
|
# gebruiken het pad dat de nginx-image ook gebruikt, zodat nginx.conf gelijk kan
|
||||||
|
# blijven aan die van Electrum Gate.
|
||||||
|
COPY tools/evolu-relay/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY tools/evolu-relay/index.html /usr/share/nginx/html/index.html
|
||||||
|
COPY whatsnext-evolu-relay/icon.png /usr/share/nginx/html/icon.png
|
||||||
|
|
||||||
|
# Geen USER hier, en dat is met opzet: nginx moet als root beginnen om poort 80 te
|
||||||
|
# nemen, en de agent schrijft in de gedeelde map. De relay hoort wél als node te
|
||||||
|
# draaien, zoals hij tot 0.5.6 deed, en dat regelt de compose met `user: node`.
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
EXPOSE 4000 80
|
||||||
|
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# De agent van Evolu Relay.
|
||||||
|
#
|
||||||
|
# Hij bedient de statuspagina en doet verder niets: lezen wat het relay-proces
|
||||||
|
# heeft opgeschreven, en opdrachten van de pagina in de postbus leggen.
|
||||||
|
#
|
||||||
|
# Waarom de agent niet zelf beslist wie er binnen mag: dat beleid hoort bij het
|
||||||
|
# proces dat de verbindingen aanneemt, en dat is de relay. Twee processen die in
|
||||||
|
# dezelfde allowlist schrijven is een wedloop die je een keer per jaar treft en
|
||||||
|
# dan niet kunt reproduceren. De agent schrijft daarom uitsluitend command.json,
|
||||||
|
# en het relay-proces past hem toe en ruimt hem op.
|
||||||
|
#
|
||||||
|
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als /app/agent.py.
|
||||||
|
# Tot 0.5.6 was het een *.template in de app-map en haalde umbreld het bij elke
|
||||||
|
# start door envsubst, en daarom staat er geen dollarteken in en komen alle
|
||||||
|
# instellingen uit de omgeving. Dat laatste is zo gebleven: de compose zet ze in
|
||||||
|
# de omgeving neer, en dat is ook de nette weg. tests/test_relay_agent.py houdt
|
||||||
|
# het dollarteken buiten de deur, voor het geval dit ooit weer een template wordt.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATE_DIR = Path(os.environ.get("RELAY_STATE_DIR", "/var/lib/relay"))
|
||||||
|
OWNERS_FILE = STATE_DIR / "owners.json"
|
||||||
|
COMMAND_FILE = STATE_DIR / "command.json"
|
||||||
|
DATABASE_FILE = STATE_DIR / "evolu-relay.db"
|
||||||
|
|
||||||
|
# De labels die de gebruiker aan een eigenaar-id hangt. Een eigen bestand, en dat
|
||||||
|
# is de kern van deze keuze: owners.json is van het relay-proces en labels.json is
|
||||||
|
# van de agent, dus er is per bestand precies één schrijver. Dat is dezelfde
|
||||||
|
# afspraak die de postbus hierboven oplevert, en de reden staat bovenaan dit
|
||||||
|
# bestand.
|
||||||
|
#
|
||||||
|
# Wat het bovendien oplevert: een label is meteen opgeslagen en niet pas als de
|
||||||
|
# relay de postbus leegmaakt, en labelen blijft werken als de relay omgevallen is.
|
||||||
|
# Dat mag, want de relay hoeft dit niet te weten: een label zegt niets over wie er
|
||||||
|
# binnen mag.
|
||||||
|
LABELS_FILE = STATE_DIR / "labels.json"
|
||||||
|
|
||||||
|
RELAY_HOST = os.environ.get("RELAY_HOST", "")
|
||||||
|
RELAY_PORT = int(os.environ.get("RELAY_PORT", "4000"))
|
||||||
|
PUBLIC_PORT = int(os.environ.get("RELAY_PUBLIC_PORT", "3852"))
|
||||||
|
API_PORT = int(os.environ.get("RELAY_API_PORT", "8000"))
|
||||||
|
PROBE_INTERVAL = int(os.environ.get("RELAY_PROBE_INTERVAL", "15"))
|
||||||
|
|
||||||
|
# De versie uit het manifest, voor de kop van de pagina. Tot 0.5.6 vulde umbreld
|
||||||
|
# die rechtstreeks in de pagina in; nu de pagina in de image zit, loopt het via de
|
||||||
|
# status. Leeg betekent dat de compose hem niet doorgeeft, en dan toont de pagina
|
||||||
|
# geen versie in plaats van een verzonnen.
|
||||||
|
APP_VERSION = os.environ.get("RELAY_APP_VERSION", "")
|
||||||
|
|
||||||
|
# Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit
|
||||||
|
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
|
||||||
|
# hier te stranden en niet daar.
|
||||||
|
ALLOWED_ACTIONS = ("block", "allow", "forget")
|
||||||
|
|
||||||
|
MAX_BODY_BYTES = 4096
|
||||||
|
MAX_OWNER_ID_LENGTH = 256
|
||||||
|
|
||||||
|
# Een label is een herkenpunt en geen aantekenveld: het staat op de pagina naast
|
||||||
|
# een id en moet daar op één regel passen.
|
||||||
|
MAX_LABEL_LENGTH = 48
|
||||||
|
|
||||||
|
# Een bovengrens op het aantal labels. De pagina zit achter de inlog van umbrelOS,
|
||||||
|
# dus dit is geen verdediging tegen een aanvaller maar tegen een lus die per
|
||||||
|
# ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft.
|
||||||
|
MAX_LABELS = 200
|
||||||
|
|
||||||
|
# Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig:
|
||||||
|
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
|
||||||
|
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
|
||||||
|
# labelen is lezen-wijzigen-schrijven. Dit slot maakt daar één handeling van.
|
||||||
|
labels_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Door de achtergrondlus bijgewerkt, door de webserver gelezen. Een dict wordt in
|
||||||
|
# zijn geheel vervangen en nooit ter plekke aangepast, zodat een lezer altijd een
|
||||||
|
# samenhangend beeld heeft zonder slot.
|
||||||
|
probe = {"reachable": None, "checked": None}
|
||||||
|
|
||||||
|
|
||||||
|
def relay_reachable():
|
||||||
|
"""Kan de relay een TCP-verbinding aannemen.
|
||||||
|
|
||||||
|
Bewust niet meer dan dat. De relay is een WebSocket-server en antwoordt niet
|
||||||
|
op een gewoon verzoek; een handdruk nabouwen om de pagina groen te krijgen is
|
||||||
|
meer code dan het waard is. Wat dit wel uitsluit is de meest voorkomende
|
||||||
|
storing: het proces is omgevallen.
|
||||||
|
"""
|
||||||
|
if not RELAY_HOST:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with socket.create_connection((RELAY_HOST, RELAY_PORT), timeout=3):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def probe_loop():
|
||||||
|
global probe
|
||||||
|
while True:
|
||||||
|
# In zijn geheel vervangen en niet ter plekke aanpassen: een lezer ziet
|
||||||
|
# dan altijd een samenhangend beeld, zonder dat er een slot nodig is.
|
||||||
|
probe = {
|
||||||
|
"reachable": relay_reachable(),
|
||||||
|
"checked": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
}
|
||||||
|
time.sleep(PROBE_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def read_owners():
|
||||||
|
"""De allowlist zoals het relay-proces hem heeft achtergelaten."""
|
||||||
|
try:
|
||||||
|
with OWNERS_FILE.open("r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Nog nooit geschreven. Dat is de normale toestand vlak na een
|
||||||
|
# installatie: het relay-proces schrijft pas bij de eerste wijziging.
|
||||||
|
return {"state": None, "problem": "nog-niet-aangemaakt"}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {"state": None, "problem": "onleesbaar"}
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"state": None, "problem": "onleesbaar"}
|
||||||
|
return {"state": data, "problem": None}
|
||||||
|
|
||||||
|
|
||||||
|
def read_labels():
|
||||||
|
"""De labels, of een lege verzameling.
|
||||||
|
|
||||||
|
Bewust vergevingsgezind, en dat is het omgekeerde van hoe owners.json gelezen
|
||||||
|
wordt. Daar hangt aan een half begrepen bestand de vraag wie er binnen mag, en
|
||||||
|
dan is weigeren het antwoord. Hier gaat het om een naam naast een id: is het
|
||||||
|
onleesbaar, dan is het ergste gevolg dat je de rauwe ids ziet.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with LABELS_FILE.open("r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
schoon = {}
|
||||||
|
for owner_id, label in data.items():
|
||||||
|
if not isinstance(owner_id, str) or not isinstance(label, str):
|
||||||
|
continue
|
||||||
|
if not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
|
||||||
|
continue
|
||||||
|
label = label.strip()
|
||||||
|
if label:
|
||||||
|
schoon[owner_id] = label[:MAX_LABEL_LENGTH]
|
||||||
|
return schoon
|
||||||
|
|
||||||
|
|
||||||
|
def write_labels(labels):
|
||||||
|
"""Schrijft de labels. Eerst een tijdelijk bestand en dan hernoemen.
|
||||||
|
|
||||||
|
Hernoemen binnen dezelfde map is atomair, dus een onderbroken schrijfactie
|
||||||
|
laat geen half bestand achter. Dezelfde constructie als de postbus.
|
||||||
|
"""
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = LABELS_FILE.with_suffix(".json.tmp")
|
||||||
|
with temporary.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(labels, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
temporary.replace(LABELS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def database_facts():
|
||||||
|
try:
|
||||||
|
stat = DATABASE_FILE.stat()
|
||||||
|
except OSError:
|
||||||
|
return {"bytes": None, "modified": None}
|
||||||
|
return {
|
||||||
|
"bytes": stat.st_size,
|
||||||
|
"modified": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def met_label(entries, labels):
|
||||||
|
"""Hangt het label van de gebruiker aan elke regel.
|
||||||
|
|
||||||
|
Een kopie en niet ter plekke: wat hier binnenkomt komt uit het bestand van het
|
||||||
|
relay-proces, en daar horen wij niets aan toe te voegen.
|
||||||
|
"""
|
||||||
|
resultaat = []
|
||||||
|
for entry in entries:
|
||||||
|
regel = dict(entry)
|
||||||
|
regel["label"] = labels.get(entry.get("id"))
|
||||||
|
resultaat.append(regel)
|
||||||
|
return resultaat
|
||||||
|
|
||||||
|
|
||||||
|
def build_status():
|
||||||
|
owners = read_owners()
|
||||||
|
state = owners["state"] or {}
|
||||||
|
labels = read_labels()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": APP_VERSION or None,
|
||||||
|
"relay": {
|
||||||
|
"reachable": probe["reachable"],
|
||||||
|
"checked": probe["checked"],
|
||||||
|
"publicPort": PUBLIC_PORT,
|
||||||
|
},
|
||||||
|
"owners": {
|
||||||
|
"problem": owners["problem"],
|
||||||
|
"allowed": met_label(
|
||||||
|
[
|
||||||
|
entry
|
||||||
|
for entry in state.get("owners", [])
|
||||||
|
if isinstance(entry, dict) and entry.get("allowed") is True
|
||||||
|
],
|
||||||
|
labels,
|
||||||
|
),
|
||||||
|
"blocked": met_label(
|
||||||
|
[
|
||||||
|
entry
|
||||||
|
for entry in state.get("owners", [])
|
||||||
|
if isinstance(entry, dict) and entry.get("allowed") is False
|
||||||
|
],
|
||||||
|
labels,
|
||||||
|
),
|
||||||
|
"rejected": met_label(
|
||||||
|
[entry for entry in state.get("rejected", []) if isinstance(entry, dict)],
|
||||||
|
labels,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"database": database_facts(),
|
||||||
|
# Ligt er nog een opdracht, dan heeft het relay-proces hem nog niet
|
||||||
|
# opgepakt. De pagina kan dat tonen in plaats van te doen alsof er niets
|
||||||
|
# gebeurd is.
|
||||||
|
"pendingCommand": COMMAND_FILE.exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_command(payload):
|
||||||
|
"""Geeft de opdracht terug, of een foutmelding.
|
||||||
|
|
||||||
|
Streng aan deze kant, want dit is de enige plek waar iets van buiten in de
|
||||||
|
postbus belandt.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None, "geen object"
|
||||||
|
|
||||||
|
action = payload.get("action")
|
||||||
|
if action not in ALLOWED_ACTIONS:
|
||||||
|
return None, "onbekende actie"
|
||||||
|
|
||||||
|
owner_id = payload.get("ownerId")
|
||||||
|
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
|
||||||
|
return None, "ontbrekende of te lange ownerId"
|
||||||
|
return {"action": action, "ownerId": owner_id}, None
|
||||||
|
|
||||||
|
|
||||||
|
def valid_label(payload):
|
||||||
|
"""Geeft (ownerId, label) terug, of een foutmelding.
|
||||||
|
|
||||||
|
Een leeg label is geen fout maar de manier om er een weg te halen: dan hoeft er
|
||||||
|
geen tweede opdracht te bestaan voor iets dat de gebruiker als hetzelfde veld
|
||||||
|
ziet.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None, None, "geen object"
|
||||||
|
|
||||||
|
owner_id = payload.get("ownerId")
|
||||||
|
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
|
||||||
|
return None, None, "ontbrekende of te lange ownerId"
|
||||||
|
|
||||||
|
label = payload.get("label")
|
||||||
|
if label is None:
|
||||||
|
label = ""
|
||||||
|
if not isinstance(label, str):
|
||||||
|
return None, None, "label moet tekst zijn"
|
||||||
|
|
||||||
|
# Regeleindes eruit: dit is één regel naast een id, en een label met een
|
||||||
|
# nieuwe regel erin zou de lijst uit elkaar trekken.
|
||||||
|
label = " ".join(label.split()).strip()
|
||||||
|
if len(label) > MAX_LABEL_LENGTH:
|
||||||
|
return None, None, "label is te lang"
|
||||||
|
|
||||||
|
return owner_id, label, None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_label(owner_id, label):
|
||||||
|
"""Zet of haalt een label weg. Geeft een foutmelding terug, of None.
|
||||||
|
|
||||||
|
Onder het slot, want dit is lezen-wijzigen-schrijven en de agent bedient
|
||||||
|
meerdere verzoeken tegelijk.
|
||||||
|
"""
|
||||||
|
with labels_lock:
|
||||||
|
labels = read_labels()
|
||||||
|
if label:
|
||||||
|
if owner_id not in labels and len(labels) >= MAX_LABELS:
|
||||||
|
return "er zijn al te veel labels"
|
||||||
|
labels[owner_id] = label
|
||||||
|
else:
|
||||||
|
if owner_id not in labels:
|
||||||
|
# Niets te doen, en dat is geen fout: de pagina stuurt een leeg
|
||||||
|
# label als je het veld leegmaakt, ook als er nog niets stond.
|
||||||
|
return None
|
||||||
|
del labels[owner_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_labels(labels)
|
||||||
|
except OSError as error:
|
||||||
|
return "label kon niet worden weggeschreven: " + str(error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_command(command):
|
||||||
|
"""Legt de opdracht in de postbus.
|
||||||
|
|
||||||
|
Eerst een tijdelijk bestand en dan hernoemen: het relay-proces kijkt op zijn
|
||||||
|
eigen moment en mag geen half bestand aantreffen.
|
||||||
|
"""
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = COMMAND_FILE.with_suffix(".json.tmp")
|
||||||
|
with temporary.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(command, handle)
|
||||||
|
handle.write("\n")
|
||||||
|
temporary.replace(COMMAND_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
# De standaardregel van BaseHTTPRequestHandler noemt de naam van de server en
|
||||||
|
# de Python-versie. Dat hoeft niemand te weten.
|
||||||
|
server_version = "evolu-relay-agent"
|
||||||
|
sys_version = ""
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
# Geen toegangslog. Elke regel zou het adres van de bezoeker bevatten en
|
||||||
|
# de pagina zit achter de inlog van umbrelOS; er valt niets te zien wat
|
||||||
|
# het bewaren waard is.
|
||||||
|
return
|
||||||
|
|
||||||
|
def _send(self, status, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.rstrip("/") in ("/api/status", "/status"):
|
||||||
|
self._send(200, build_status())
|
||||||
|
return
|
||||||
|
self._send(404, {"error": "onbekend pad"})
|
||||||
|
|
||||||
|
def _read_payload(self):
|
||||||
|
"""Het verzoek als object, of None als er al een fout verstuurd is."""
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
self._send(400, {"error": "lengte ontbreekt"})
|
||||||
|
return None
|
||||||
|
|
||||||
|
if length <= 0 or length > MAX_BODY_BYTES:
|
||||||
|
self._send(400, {"error": "lege of te grote opdracht"})
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
self._send(400, {"error": "onleesbare opdracht"})
|
||||||
|
return None
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
pad = self.path.rstrip("/")
|
||||||
|
|
||||||
|
# Een label gaat NIET via de postbus, en dat is de enige uitzondering op
|
||||||
|
# die regel. De reden dat opdrachten er wel door gaan, is dat het
|
||||||
|
# relay-proces beslist wie er binnen mag en dat twee schrijvers in die
|
||||||
|
# allowlist een wedloop zou zijn. Een label zegt niets over toegang, staat
|
||||||
|
# in een eigen bestand met de agent als enige schrijver, en is meteen
|
||||||
|
# opgeslagen in plaats van na de volgende ronde van de relay.
|
||||||
|
if pad in ("/api/label", "/label"):
|
||||||
|
payload = self._read_payload()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
owner_id, label, problem = valid_label(payload)
|
||||||
|
if problem is not None:
|
||||||
|
self._send(400, {"error": problem})
|
||||||
|
return
|
||||||
|
|
||||||
|
problem = apply_label(owner_id, label)
|
||||||
|
if problem is not None:
|
||||||
|
self._send(500, {"error": problem})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send(200, {"ownerId": owner_id, "label": label})
|
||||||
|
return
|
||||||
|
|
||||||
|
if pad not in ("/api/command", "/command"):
|
||||||
|
self._send(404, {"error": "onbekend pad"})
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = self._read_payload()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
command, problem = valid_command(payload)
|
||||||
|
if command is None:
|
||||||
|
self._send(400, {"error": problem})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Eén opdracht tegelijk. Ligt er nog een, dan zou schrijven hem stil
|
||||||
|
# overschrijven en verdwijnt de vorige zonder dat iemand het merkt.
|
||||||
|
if COMMAND_FILE.exists():
|
||||||
|
self._send(409, {"error": "vorige opdracht is nog niet verwerkt"})
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_command(command)
|
||||||
|
except OSError as error:
|
||||||
|
self._send(500, {"error": "opdracht kon niet worden weggeschreven: " + str(error)})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send(202, {"accepted": command})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
threading.Thread(target=probe_loop, daemon=True).start()
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", API_PORT), Handler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Bouwt de image voor de app whatsnext-evolu-relay.
|
||||||
|
#
|
||||||
|
# Eén image voor de drie containers van de app: de relay (ons `src/index.js` om
|
||||||
|
# `@evolu/nodejs` heen), de agent (`agent.py`) en nginx met de pagina. De relay
|
||||||
|
# zelf komt uit npm en wordt niet nagebouwd of aangepast. Tot 0.5.6 zat alleen de
|
||||||
|
# relay in deze image; sinds 0.6.0 alles, zie de Dockerfile.
|
||||||
|
#
|
||||||
|
# Dit verving op 28-08-2026 het vorige recept, dat de repo van Trezor kloonde en
|
||||||
|
# hun Dockerfile bouwde. Dat pakket had een Postgres en een quota-manager nodig en
|
||||||
|
# kon als zelf-gehoste relay in de kern niet werken; zie het plan Umbrelapp,
|
||||||
|
# OPEN.md punt 6.
|
||||||
|
#
|
||||||
|
# Waarom dit NIET in de app-map staat: umbreld kopieert de hele app-map naar het
|
||||||
|
# apparaat, en bij een update wordt alleen een whitelist ververst waar een
|
||||||
|
# Dockerfile niet in zit. Bouwen tijdens het starten van de app zou de installatie
|
||||||
|
# bovendien minuten laten hangen. Het recept hoort dus in de repo en het resultaat
|
||||||
|
# in een register; de app verwijst alleen naar de tag.
|
||||||
|
#
|
||||||
|
# Draaien: sh tools/evolu-relay/build.sh
|
||||||
|
# Vereist: docker op de machine waar je bouwt. Op de Umbrel is dat met sudo.
|
||||||
|
#
|
||||||
|
# Multi-arch: PLATFORMS="linux/amd64,linux/arm64" sh tools/evolu-relay/build.sh
|
||||||
|
# Vraagt buildx met QEMU en duwt meteen. Nog nooit geprobeerd; zie het masterplan
|
||||||
|
# Publicatie-Relay. Zonder PLATFORMS bouwt dit één architectuur, die van de
|
||||||
|
# machine waarop je staat.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# ── De pin ────────────────────────────────────────────────────────────────────
|
||||||
|
# De versies van `@evolu/common` en `@evolu/nodejs` staan exact in package.json en
|
||||||
|
# zijn dáár de pin. Hier staat alleen het etiket op de uitkomst.
|
||||||
|
#
|
||||||
|
# Dit is het etiket op de IMAGE. Verhoog het als de image verandert, dus als er
|
||||||
|
# iets in deze map of in icon.png wijzigt, en zet dan dezelfde waarde achter
|
||||||
|
# `image:` in de compose (drie keer: relay, agent en server delen hem).
|
||||||
|
#
|
||||||
|
# `version` in umbrel-app.yml is een ander nummer en mag hierop vooruitlopen: dat
|
||||||
|
# moet bij élke wijziging aan de app omhoog, ook als alleen de compose verandert,
|
||||||
|
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
|
||||||
|
# Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je
|
||||||
|
# VERSION, dan ook het manifest.
|
||||||
|
VERSION="0.7.1"
|
||||||
|
|
||||||
|
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
|
||||||
|
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
|
||||||
|
# hem onbereikbaar en de installatie faalt met "pull access denied". Dat is op
|
||||||
|
# 25-08-2026 op het apparaat vastgesteld. Zie Umbrel-appstore-spec.md.
|
||||||
|
IMAGE="sc.kamenier-hamer.nl/sysop/evolu-relay:${VERSION}"
|
||||||
|
|
||||||
|
RECEPT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
# De bouwcontext is de repo-root, want icon.png komt uit de app-map. Zie de kop
|
||||||
|
# van de Dockerfile.
|
||||||
|
REPO="$(CDPATH= cd -- "${RECEPT}/../.." && pwd)"
|
||||||
|
|
||||||
|
# ── Controles vooraf ──────────────────────────────────────────────────────────
|
||||||
|
# Beter hier hard falen dan een image bouwen die iets anders bevat dan je denkt.
|
||||||
|
|
||||||
|
for bestand in Dockerfile package.json src/index.js agent.py nginx.conf index.html; do
|
||||||
|
if [ ! -f "${RECEPT}/${bestand}" ]; then
|
||||||
|
echo "FOUT: ${bestand} ontbreekt in ${RECEPT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "${REPO}/whatsnext-evolu-relay/icon.png" ]; then
|
||||||
|
echo "FOUT: whatsnext-evolu-relay/icon.png ontbreekt in ${REPO}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "${RECEPT}/package-lock.json" ]; then
|
||||||
|
echo "LET OP: er is geen package-lock.json, dus npm kiest zelf de onderliggende"
|
||||||
|
echo " afhankelijkheden. De twee pakketten van Evolu staan exact vast in"
|
||||||
|
echo " package.json, maar wat daaronder hangt kan meebewegen. Draai eenmalig"
|
||||||
|
echo " 'npm install' in deze map, commit het lock-bestand, en zet dan in de"
|
||||||
|
echo " Dockerfile 'npm install' om naar 'npm ci'."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Een accolade-variabele hoort hier nergens meer in: deze bestanden gaan niet
|
||||||
|
# meer door envsubst, dus wat er staat is wat er draait. Staat er tóch een, dan
|
||||||
|
# is dat een restant van de template-tijd en zou hij letterlijk in de pagina of
|
||||||
|
# de configuratie terechtkomen.
|
||||||
|
if grep -l '\${' "${RECEPT}/nginx.conf" "${RECEPT}/index.html" "${RECEPT}/agent.py" 2>/dev/null; then
|
||||||
|
echo "FOUT: de bestanden hierboven bevatten nog een \${...}; die wordt niet meer ingevuld." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Bouwen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PLATFORMS="${PLATFORMS:-}"
|
||||||
|
|
||||||
|
if [ -n "$PLATFORMS" ]; then
|
||||||
|
echo "Bouwen als ${IMAGE} voor ${PLATFORMS}, en meteen duwen"
|
||||||
|
docker buildx build --platform "$PLATFORMS" --tag "$IMAGE" --push \
|
||||||
|
--file "${RECEPT}/Dockerfile" "$REPO"
|
||||||
|
echo
|
||||||
|
echo "Geduwd. De index-digest:"
|
||||||
|
docker buildx imagetools inspect "$IMAGE" | head -3
|
||||||
|
else
|
||||||
|
echo "Bouwen als ${IMAGE}"
|
||||||
|
docker build --tag "$IMAGE" --file "${RECEPT}/Dockerfile" "$REPO"
|
||||||
|
echo
|
||||||
|
echo "Klaar:"
|
||||||
|
docker image inspect --format '{{.RepoTags}} {{.Id}}' "$IMAGE"
|
||||||
|
echo
|
||||||
|
echo "Bouwen is niet genoeg: de app verwijst naar het register, want umbreld kan"
|
||||||
|
echo "niet bij een image die alleen lokaal staat. Nog te doen:"
|
||||||
|
echo
|
||||||
|
echo " docker login sc.kamenier-hamer.nl"
|
||||||
|
echo " docker push ${IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Zet daarna de digest uit de push-uitvoer achter de tag in"
|
||||||
|
echo "whatsnext-evolu-relay/docker-compose.yml (drie keer: relay, agent en server),"
|
||||||
|
echo "en zet \`version\` in het manifest op ${VERSION}. Zonder die verhoging rolt"
|
||||||
|
echo "umbrelOS het niet uit."
|
||||||
|
echo
|
||||||
|
echo "Controleer dat het anoniem te halen is, want umbreld krijgt geen"
|
||||||
|
echo "inloggegevens mee. Log uit in dezelfde context waarin je inlogde, anders"
|
||||||
|
echo "meet je je eigen sessie:"
|
||||||
|
echo
|
||||||
|
echo " docker logout sc.kamenier-hamer.nl && docker pull ${IMAGE}"
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
|||||||
|
# nginx-configuratie voor Evolu Relay.
|
||||||
|
#
|
||||||
|
# Dit bestand serveert alleen de statuspagina. De relay zelf zit er niet achter:
|
||||||
|
# die publiceert zijn eigen poort in docker-compose.yml, waar Zoraxy met TLS
|
||||||
|
# naartoe wijst. Dat is de omkering ten opzichte van het pakket van 25-08-2026,
|
||||||
|
# waar de relay achter de app-proxy stond en de inlog dus uit moest; zie het plan
|
||||||
|
# Umbrelapp, PLAN.md §4h.
|
||||||
|
#
|
||||||
|
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als
|
||||||
|
# /etc/nginx/nginx.conf. Tot 0.5.6 was het een *.template in de app-map die
|
||||||
|
# umbreld bij elke start door envsubst haalde, en daarom mocht er toen geen
|
||||||
|
# nginx-variabele met een dollarteken in staan. Die beperking is weg. Dat de
|
||||||
|
# access_log nog steeds uit staat is een keuze en geen restant: de pagina hangt
|
||||||
|
# achter de app_proxy en wat die opvraagt is niet interessant.
|
||||||
|
|
||||||
|
# www-data en niet nginx: deze nginx komt uit Debian (de image is node:24-slim
|
||||||
|
# met nginx uit apt), en Debian heeft geen gebruiker nginx. Bij Electrum Gate
|
||||||
|
# staat hier wél nginx, want die image is de officiële nginx-image.
|
||||||
|
user www-data;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
access_log off;
|
||||||
|
sendfile on;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# De API van de agent: de status lezen en een opdracht in de postbus
|
||||||
|
# leggen. Dit is het enige pad waarlangs iets van buiten de instellingen
|
||||||
|
# van de app raakt.
|
||||||
|
#
|
||||||
|
# Waarom dat hier te verantwoorden is: dit pad hangt achter de app_proxy
|
||||||
|
# van umbrelOS, die er zijn eigen inlog voor zet. De relay staat op een
|
||||||
|
# andere poort en is hierlangs niet te bereiken. En het ergste wat een
|
||||||
|
# geslaagde aanroep doet is de leerstand omzetten of een eigenaar
|
||||||
|
# blokkeren, en dat is precies waar de pagina voor is.
|
||||||
|
#
|
||||||
|
# Geen proxy_set_header: de agent heeft ze niet nodig.
|
||||||
|
#
|
||||||
|
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
|
||||||
|
# eerst, en op het apparaat gaf het een 404 bij ongeveer elke tweede
|
||||||
|
# aanroep (28-08-2026): Electrum Gate heeft óók een service 'agent' op
|
||||||
|
# poort 8000, beide apps zitten op hetzelfde Docker-netwerk, en de korte
|
||||||
|
# naam kan dus naar de verkeerde container wijzen. De vorm
|
||||||
|
# <app-id>_<service>_1 is uniek per app. Dezelfde les stond al in de
|
||||||
|
# compose van Electrum Gate, over de servicenaam 'server'.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://whatsnext-evolu-relay_agent_1:8000;
|
||||||
|
# Een opdracht is een handvol bytes. De agent kapt zelf ook af, op
|
||||||
|
# 4k; dit is de eerste zeef en niet de enige.
|
||||||
|
client_max_body_size 4k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "whatsnext-evolu-relay",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "De relay van Evolu met een eigen eigenaars-allowlist eromheen.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@evolu/common": "8.7.0",
|
||||||
|
"@evolu/nodejs": "3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// De relay van Evolu, met onze eigen toegangscontrole eromheen.
|
||||||
|
//
|
||||||
|
// Wat dit programma NIET is: een eigen relay. `createRelay` uit `@evolu/nodejs`
|
||||||
|
// doet al het werk; het enige dat wij toevoegen zijn de twee terugroepfuncties
|
||||||
|
// die het bedoelde uitbreidpunt vormen. Trezor doet in `createEvoluRelay.ts`
|
||||||
|
// precies hetzelfde, alleen kijken hun functies in een Postgres.
|
||||||
|
//
|
||||||
|
// De opstartvolgorde en de `runMain`-aanroep zijn overgenomen uit
|
||||||
|
// `apps/relay/src/index.ts` van Evolu zelf. Wijk daar niet van af zonder reden:
|
||||||
|
// dat bestand is de referentie voor hoe deze relay hoort te starten.
|
||||||
|
//
|
||||||
|
// Zie het plan Umbrelapp, PLAN.md §4g, en Referenties/Upstream-evolu-relay.md §8.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { createConsole, createConsoleFormatter } from '@evolu/common';
|
||||||
|
import { installPolyfills } from '@evolu/common/polyfills';
|
||||||
|
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { applyCommand, decideOwner } from './policy.js';
|
||||||
|
import { readState, takeCommand, writeState } from './store.js';
|
||||||
|
|
||||||
|
installPolyfills();
|
||||||
|
|
||||||
|
// Zoals bovenstrooms: de database komt in een map `data` naast het programma,
|
||||||
|
// zodat het volume van de container een voorspelbaar pad heeft. Onze allowlist
|
||||||
|
// komt in diezelfde map te staan, naast de database en niet erin.
|
||||||
|
mkdirSync('data', { recursive: true });
|
||||||
|
process.chdir('data');
|
||||||
|
|
||||||
|
const dataDir = process.cwd();
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.RELAY_PORT ?? '4000', 10);
|
||||||
|
|
||||||
|
// Per schrijfactie, niet per eigenaar. Dat is hoe `isOwnerWithinQuota` bij Evolu
|
||||||
|
// bedoeld is: de relay geeft door hoeveel bytes déze actie nodig heeft. Trezor
|
||||||
|
// telt er zelf een totaal bij op uit hun eigen tabel; wij hebben die tabel niet en
|
||||||
|
// doen dat dus niet.
|
||||||
|
//
|
||||||
|
// De standaard is dezelfde 1 MB als de gepubliceerde image, en dat is een bewuste
|
||||||
|
// keuze en geen overname: een enkele labelwijziging is klein, dus een schrijfactie
|
||||||
|
// die hierboven uitkomt is eerder een fout of misbruik dan normaal gebruik.
|
||||||
|
const maxWriteBytes = Number.parseInt(process.env.RELAY_MAX_WRITE_BYTES ?? '1048576', 10);
|
||||||
|
|
||||||
|
const console = createConsole({
|
||||||
|
formatter: createConsoleFormatter()({ timestampFormat: 'relative' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── De staat ──────────────────────────────────────────────────────────────────
|
||||||
|
// In het geheugen gezaghebbend, op schijf bewaard. `isOwnerAllowed` zit in het
|
||||||
|
// verbindingspad, dus daar wordt niet naar schijf geschreven; dat doet de
|
||||||
|
// achtergrondlus hieronder.
|
||||||
|
|
||||||
|
const start = readState(dataDir, new Date().toISOString());
|
||||||
|
let state = start.state;
|
||||||
|
let dirty = start.status === 'new';
|
||||||
|
|
||||||
|
if (start.status === 'corrupt') {
|
||||||
|
console.log(
|
||||||
|
'[warn] owners.json was unreadable and has been moved aside. Everyone is on the waiting list ' +
|
||||||
|
'again; allow the owners you recognise on the status page.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markDirty = () => {
|
||||||
|
dirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!dirty) return;
|
||||||
|
try {
|
||||||
|
writeState(dataDir, state);
|
||||||
|
dirty = false;
|
||||||
|
} catch (error) {
|
||||||
|
// Niet fataal: de relay blijft werken met de staat in het geheugen. Wel
|
||||||
|
// melden, want na een herstart is de allowlist dan verouderd.
|
||||||
|
console.log(`[warn] could not write owners.json: ${String(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Opdrachten van de statuspagina ────────────────────────────────────────────
|
||||||
|
// De pagina schrijft een bestand, dit proces past het toe. Geen Docker-socket en
|
||||||
|
// geen tweede container die in dezelfde staat schrijft; dezelfde afspraak als bij
|
||||||
|
// Electrum Gate.
|
||||||
|
|
||||||
|
const pollCommands = () => {
|
||||||
|
const command = takeCommand(dataDir);
|
||||||
|
if (command === null) return;
|
||||||
|
|
||||||
|
const result = applyCommand(state, command, new Date().toISOString());
|
||||||
|
if (result.error !== null) {
|
||||||
|
console.log(`[warn] command refused: ${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.changed) {
|
||||||
|
state = result.state;
|
||||||
|
markDirty();
|
||||||
|
console.log(`[info] command applied: ${command.action}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
pollCommands();
|
||||||
|
flush();
|
||||||
|
}, 2000).unref();
|
||||||
|
|
||||||
|
const stopCleanly = () => {
|
||||||
|
flush();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', stopCleanly);
|
||||||
|
process.on('SIGINT', stopCleanly);
|
||||||
|
|
||||||
|
// ── De relay ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Eén regel per eigenaar per keer dat dit proces draait, en niet per verbinding.
|
||||||
|
// Zonder deze rem staat het log vol zodra een cliënt opnieuw verbindt; zónder
|
||||||
|
// logging kostte het op 28-08-2026 juist een half uur om te zien dat een tweede
|
||||||
|
// eigenaar aan de deur stond te kloppen. De bovengrens erop is er omdat een
|
||||||
|
// weigering een id bevat dat de ander zelf verzint.
|
||||||
|
const MAX_ANNOUNCED = 100;
|
||||||
|
const announced = new Set();
|
||||||
|
|
||||||
|
const announceOnce = (key, message) => {
|
||||||
|
if (announced.has(key)) return;
|
||||||
|
if (announced.size < MAX_ANNOUNCED) announced.add(key);
|
||||||
|
console.log(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
await runMain({ ...createRelayDeps(), console })(
|
||||||
|
createRelay({
|
||||||
|
port,
|
||||||
|
|
||||||
|
isOwnerAllowed: (ownerId) => {
|
||||||
|
let decision;
|
||||||
|
try {
|
||||||
|
decision = decideOwner(state, ownerId, new Date().toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
// Bij twijfel weigeren. Een fout in het beleid mag geen open deur worden.
|
||||||
|
console.log(`[warn] policy failed, refusing owner: ${String(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = decision.state;
|
||||||
|
if (decision.changed) markDirty();
|
||||||
|
|
||||||
|
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
|
||||||
|
if (decision.allowed) {
|
||||||
|
announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
|
||||||
|
} else {
|
||||||
|
announceOnce(
|
||||||
|
`no:${short}`,
|
||||||
|
`[info] owner ${short} is on the waiting list (${decision.reason}); allow it on the ` +
|
||||||
|
'status page if it is yours',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decision.allowed;
|
||||||
|
},
|
||||||
|
|
||||||
|
isOwnerWithinQuota: (ownerId, requiredBytes) => {
|
||||||
|
if (requiredBytes <= maxWriteBytes) return true;
|
||||||
|
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
|
||||||
|
announceOnce(
|
||||||
|
`quota:${short}`,
|
||||||
|
`[warn] refused a write of ${requiredBytes} bytes from ${short}; the limit is ` +
|
||||||
|
`${maxWriteBytes}. Raise RELAY_MAX_WRITE_BYTES if this is normal for your app.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
||||||
|
// onbekende eigenaar.
|
||||||
|
//
|
||||||
|
// **Er is sinds 0.7.0 (09-09-2026) precies één weg naar binnen: de wachtlijst.**
|
||||||
|
// Een onbekende eigenaar wordt geweigerd en onthouden; op de statuspagina staat
|
||||||
|
// hij met zijn `OwnerId` erbij en daar laat je hem toe. De cliënt verbindt
|
||||||
|
// vanzelf opnieuw en is dan binnen.
|
||||||
|
//
|
||||||
|
// Tot 0.6.0 was er daarnaast een getimede leerstand die de eerstvolgende
|
||||||
|
// onbekende eigenaars toeliet. Die is eruit op voorstel van de gebruiker, en de
|
||||||
|
// reden is dat het dubbelop was: allebei de wegen vragen iemand die bij de app
|
||||||
|
// kan, en het venster is de zwakste van de twee omdat het iederéén toelaat die
|
||||||
|
// er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek
|
||||||
|
// wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je
|
||||||
|
// eigen `OwnerId` nergens kon aflezen, en die reden verviel toen de weigerlijst
|
||||||
|
// dat id ging tonen.
|
||||||
|
//
|
||||||
|
// De velden `learning` en `learningUntil` worden nog wél gelézen, en dat is met
|
||||||
|
// opzet: `STATE_VERSION` verhogen zou `normalizeState` een bestaand bestand
|
||||||
|
// laten afwijzen, en dan schuift `store.js` de allowlist van een werkende
|
||||||
|
// installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
|
||||||
|
// Ze worden gelezen, genegeerd en niet teruggeschreven.
|
||||||
|
//
|
||||||
|
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
|
||||||
|
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
|
||||||
|
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
|
||||||
|
// er binnenkomt, en zulke logica hoort te testen te zijn zonder relay en zonder
|
||||||
|
// Docker.
|
||||||
|
//
|
||||||
|
// De relay van Evolu roept `isOwnerAllowed(ownerId)` aan. Wij beantwoorden die
|
||||||
|
// vraag hier; `src/index.js` doet niets anders dan lezen, doorgeven en opslaan.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const STATE_VERSION = 1;
|
||||||
|
|
||||||
|
// Hoeveel wachtenden we onthouden om op de pagina te tonen. Er zit een grens op
|
||||||
|
// omdat een onbekende die blijft proberen anders het bestand vol schrijft: elke
|
||||||
|
// poging is een eigenaar-id dat hij zelf verzint.
|
||||||
|
//
|
||||||
|
// Het veld heet op schijf nog `rejected` en dat blijft zo. Op de pagina heet het
|
||||||
|
// sinds 0.7.0 de wachtlijst, want dat is wat het is: kandidaten die op een
|
||||||
|
// beslissing wachten. De naam in het bestand hernoemen zou een migratie zijn, en
|
||||||
|
// die is het niet waard voor een woord dat niemand ziet.
|
||||||
|
export const MAX_REJECTED = 20;
|
||||||
|
|
||||||
|
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
|
||||||
|
// buiten en belandt in een bestand. Daarom een bovengrens en een typecontrole, en
|
||||||
|
// verder geen aannames over de vorm.
|
||||||
|
export const MAX_OWNER_ID_LENGTH = 256;
|
||||||
|
|
||||||
|
export const createEmptyState = () => ({
|
||||||
|
version: STATE_VERSION,
|
||||||
|
owners: [],
|
||||||
|
rejected: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUsableOwnerId = (value) =>
|
||||||
|
typeof value === 'string' && value.length > 0 && value.length <= MAX_OWNER_ID_LENGTH;
|
||||||
|
|
||||||
|
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
|
||||||
|
*
|
||||||
|
* Bewust streng: een half begrepen bestand is gevaarlijker dan geen bestand,
|
||||||
|
* want dan denkt de app dat er een allowlist is terwijl die leeg is. De
|
||||||
|
* aanroeper beslist wat er bij `null` gebeurt, en die keuze staat in `store.js`.
|
||||||
|
*/
|
||||||
|
export const normalizeState = (raw) => {
|
||||||
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
if (raw.version !== STATE_VERSION) return null;
|
||||||
|
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
|
||||||
|
|
||||||
|
// `learning` en `learningUntil` mogen er nog in staan en worden overgeslagen.
|
||||||
|
// Een bestand van 0.6.0 of ouder heeft ze, en dat bestand hoort gewoon te
|
||||||
|
// lezen; zie de kop van dit bestand voor waarom hier geen versieverhoging
|
||||||
|
// tegenover staat.
|
||||||
|
|
||||||
|
const owners = [];
|
||||||
|
for (const owner of raw.owners) {
|
||||||
|
if (owner === null || typeof owner !== 'object') return null;
|
||||||
|
if (!isUsableOwnerId(owner.id)) return null;
|
||||||
|
if (typeof owner.allowed !== 'boolean') return null;
|
||||||
|
owners.push({
|
||||||
|
id: owner.id,
|
||||||
|
allowed: owner.allowed,
|
||||||
|
firstSeen: typeof owner.firstSeen === 'string' ? owner.firstSeen : null,
|
||||||
|
lastSeen: typeof owner.lastSeen === 'string' ? owner.lastSeen : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejected = [];
|
||||||
|
for (const entry of raw.rejected) {
|
||||||
|
if (entry === null || typeof entry !== 'object') return null;
|
||||||
|
if (!isUsableOwnerId(entry.id)) return null;
|
||||||
|
rejected.push({
|
||||||
|
id: entry.id,
|
||||||
|
firstSeen: typeof entry.firstSeen === 'string' ? entry.firstSeen : null,
|
||||||
|
lastSeen: typeof entry.lastSeen === 'string' ? entry.lastSeen : null,
|
||||||
|
attempts: Number.isInteger(entry.attempts) && entry.attempts > 0 ? entry.attempts : 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: STATE_VERSION, owners, rejected };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rememberWaiting = (rejected, ownerId, now) => {
|
||||||
|
const existing = rejected.find((entry) => entry.id === ownerId);
|
||||||
|
if (existing) {
|
||||||
|
return rejected.map((entry) =>
|
||||||
|
entry.id === ownerId ? { ...entry, lastSeen: now, attempts: entry.attempts + 1 } : entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Nieuwste vooraan, en afkappen op MAX_REJECTED. De oudste valt eraf; dat is
|
||||||
|
// hier de juiste kant om te verliezen, want een aanvaller die blijft proberen
|
||||||
|
// duwt dan zijn eigen eerdere pogingen weg en niet de allowlist.
|
||||||
|
return [{ id: ownerId, firstSeen: now, lastSeen: now, attempts: 1 }, ...rejected].slice(
|
||||||
|
0,
|
||||||
|
MAX_REJECTED,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beslist of deze eigenaar erin mag, en geeft de staat terug zoals hij daarna is.
|
||||||
|
*
|
||||||
|
* @returns {{allowed: boolean, state: object, reason: string, changed: boolean}}
|
||||||
|
*/
|
||||||
|
export const decideOwner = (state, ownerId, now) => {
|
||||||
|
if (!isUsableOwnerId(ownerId)) {
|
||||||
|
// Geen bruikbaar id: weigeren en niets onthouden. Onthouden zou hier juist
|
||||||
|
// de weg openzetten om het bestand vol te schrijven met rommel.
|
||||||
|
return { allowed: false, state, reason: 'invalid-owner-id', changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = findOwner(state, ownerId);
|
||||||
|
|
||||||
|
if (known) {
|
||||||
|
const seen = { ...known, lastSeen: now };
|
||||||
|
const owners = state.owners.map((owner) => (owner.id === ownerId ? seen : owner));
|
||||||
|
return {
|
||||||
|
allowed: known.allowed,
|
||||||
|
state: { ...state, owners },
|
||||||
|
reason: known.allowed ? 'known-allowed' : 'known-blocked',
|
||||||
|
changed: known.lastSeen !== now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onbekend: weigeren en op de wachtlijst zetten. Dat is sinds 0.7.0 de enige
|
||||||
|
// uitkomst voor een onbekende, en daarmee ook de enige weg naar binnen: op de
|
||||||
|
// statuspagina staat hij met zijn `OwnerId` erbij en daar laat je hem toe.
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
state: { ...state, rejected: rememberWaiting(state.rejected, ownerId, now) },
|
||||||
|
reason: 'waiting',
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voert een opdracht van de statuspagina uit.
|
||||||
|
*
|
||||||
|
* De pagina schrijft een opdrachtbestand en dit proces past hem toe; er is geen
|
||||||
|
* Docker-socket en geen tweede container die in dezelfde staat schrijft. Dat is
|
||||||
|
* dezelfde afspraak als bij Electrum Gate.
|
||||||
|
*
|
||||||
|
* @returns {{state: object, changed: boolean, error: string|null}}
|
||||||
|
*/
|
||||||
|
export const applyCommand = (state, command, now) => {
|
||||||
|
const unchanged = (error) => ({ state, changed: false, error });
|
||||||
|
|
||||||
|
if (command === null || typeof command !== 'object') return unchanged('malformed-command');
|
||||||
|
|
||||||
|
switch (command.action) {
|
||||||
|
case 'block': {
|
||||||
|
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||||
|
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
|
||||||
|
const owners = state.owners.map((owner) =>
|
||||||
|
owner.id === command.ownerId ? { ...owner, allowed: false, lastSeen: owner.lastSeen } : owner,
|
||||||
|
);
|
||||||
|
return { state: { ...state, owners }, changed: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'allow': {
|
||||||
|
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||||
|
const known = findOwner(state, command.ownerId);
|
||||||
|
// Ook een eigenaar die alleen als geweigerde poging bekend is, mag hiermee
|
||||||
|
// alsnog toegelaten worden. Dat is de knop naast zo'n regel op de pagina.
|
||||||
|
//
|
||||||
|
// De geschiedenis van die pogingen gaat mee. Dat leek een detail tot het
|
||||||
|
// op 28-08-2026 misging: de pagina meldde daarna "first seen" op het moment
|
||||||
|
// van de klik, terwijl die eigenaar al twintig minuten aan de deur stond.
|
||||||
|
// Juist bij een eigenaar die je alsnog toelaat wil je kunnen terugzien
|
||||||
|
// sinds wanneer hij er is.
|
||||||
|
const attempted = state.rejected.find((entry) => entry.id === command.ownerId);
|
||||||
|
const owners = known
|
||||||
|
? state.owners.map((owner) =>
|
||||||
|
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
...state.owners,
|
||||||
|
{
|
||||||
|
id: command.ownerId,
|
||||||
|
allowed: true,
|
||||||
|
firstSeen: attempted && attempted.firstSeen ? attempted.firstSeen : now,
|
||||||
|
lastSeen: attempted ? attempted.lastSeen : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
owners,
|
||||||
|
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
|
||||||
|
},
|
||||||
|
changed: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'forget': {
|
||||||
|
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||||
|
const inOwners = Boolean(findOwner(state, command.ownerId));
|
||||||
|
const inRejected = state.rejected.some((entry) => entry.id === command.ownerId);
|
||||||
|
if (!inOwners && !inRejected) return unchanged('unknown-owner');
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
owners: state.owners.filter((owner) => owner.id !== command.ownerId),
|
||||||
|
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
|
||||||
|
},
|
||||||
|
changed: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return unchanged('unknown-action');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// De staat op schijf: de allowlist en het opdrachtbestand van de statuspagina.
|
||||||
|
//
|
||||||
|
// Dit is de enige plek met bestanden erin. Het beleid zelf staat in `policy.js`
|
||||||
|
// en weet hier niets van.
|
||||||
|
//
|
||||||
|
// Waar het staat: onder `${APP_DATA_DIR}/data/`, náást de database van de relay
|
||||||
|
// en niet erin. De relay bezit zijn eigen SQLite en daar schrijven wij niet in.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
import { createEmptyState, normalizeState } from './policy.js';
|
||||||
|
|
||||||
|
export const stateFileName = 'owners.json';
|
||||||
|
export const commandFileName = 'command.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leest de allowlist.
|
||||||
|
*
|
||||||
|
* Drie uitkomsten, en de derde is de interessante:
|
||||||
|
* - `new`: er is nog niets, dus een verse staat met de leerstand aan;
|
||||||
|
* - `ok`: gelezen en begrepen;
|
||||||
|
* - `corrupt`: er stond iets wat we niet begrijpen. Dan schuiven we het bestand
|
||||||
|
* opzij en beginnen we met de leerstand **uit**.
|
||||||
|
*
|
||||||
|
* Die laatste keuze is bewust en volgt de regel die deze store al kent van
|
||||||
|
* Electrum Gate: bij twijfel weigert de app. Een onleesbaar bestand betekent dat
|
||||||
|
* we niet weten wie er toegelaten was; met de leerstand aan zou de eerstvolgende
|
||||||
|
* die verbindt stilzwijgend de nieuwe eigenaar worden, en dat hoeft je eigen
|
||||||
|
* apparaat niet te zijn. Dan liever een app die niemand toelaat en dat op de
|
||||||
|
* pagina toont.
|
||||||
|
*/
|
||||||
|
export const readState = (dataDir, now) => {
|
||||||
|
const file = join(dataDir, stateFileName);
|
||||||
|
if (!existsSync(file)) return { state: createEmptyState(), status: 'new' };
|
||||||
|
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = parsed === null ? null : normalizeState(parsed);
|
||||||
|
if (state !== null) return { state, status: 'ok' };
|
||||||
|
|
||||||
|
// Opzij schuiven en niet weggooien: als dit ooit gebeurt, wil je kunnen zien
|
||||||
|
// wat erin stond.
|
||||||
|
const aside = `${file}.corrupt-${now.replace(/[:.]/g, '-')}`;
|
||||||
|
try {
|
||||||
|
renameSync(file, aside);
|
||||||
|
} catch {
|
||||||
|
// Lukt het opzij schuiven niet, dan gaan we alsnog dicht. De melding hieronder
|
||||||
|
// is dan het enige dat de gebruiker heeft, en dat is nog steeds beter dan
|
||||||
|
// stilzwijgend iemand toelaten.
|
||||||
|
}
|
||||||
|
return { state: { ...createEmptyState(), learning: false }, status: 'corrupt' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schrijft de allowlist. Eerst naar een tijdelijk bestand en dan hernoemen, zodat
|
||||||
|
* een onderbroken schrijfactie geen half bestand achterlaat: hernoemen binnen
|
||||||
|
* dezelfde map is atomair.
|
||||||
|
*/
|
||||||
|
export const writeState = (dataDir, state) => {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
const file = join(dataDir, stateFileName);
|
||||||
|
const temporary = `${file}.tmp`;
|
||||||
|
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||||
|
renameSync(temporary, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt een opdracht van de statuspagina op en verwijdert het bestand meteen.
|
||||||
|
*
|
||||||
|
* Verwijderen vóór het uitvoeren is met opzet: een opdracht die het proces laat
|
||||||
|
* struikelen mag niet bij elke ronde opnieuw worden geprobeerd.
|
||||||
|
*/
|
||||||
|
export const takeCommand = (dataDir) => {
|
||||||
|
const file = join(dataDir, commandFileName);
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(file, 'utf8');
|
||||||
|
} catch {
|
||||||
|
raw = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
unlinkSync(file);
|
||||||
|
} catch {
|
||||||
|
// Niets aan te doen; de volgende ronde probeert het opnieuw.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** De map waarin de staat hoort, afgeleid van het pad van de relay-database. */
|
||||||
|
export const dataDirectoryFor = (relayDatabaseFile) => dirname(relayDatabaseFile);
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# De bronbestanden van de app-iconen
|
||||||
|
|
||||||
|
Hier hoort het werkbestand waaruit `icon.png` van een app komt: de gelaagde versie waarin je nog
|
||||||
|
kunt bewerken. Eén bestand per app, met de app-id als naam.
|
||||||
|
|
||||||
|
**Op dit moment staat er niets.** Beide iconen zijn buiten deze repo gemaakt. Op 28-08-2026 begon de
|
||||||
|
gebruiker aan een 3D-icoon voor Evolu Relay in Paint.NET; dat was op 30-08-2026 nog niet mooi genoeg
|
||||||
|
en is door hem weggehaald. Komt er een volgende poging, dan is dít de map.
|
||||||
|
|
||||||
|
## Waarom hier en niet in de app-map
|
||||||
|
|
||||||
|
**umbreld kopieert bij een installatie de héle app-map naar het apparaat**, met `rsync --archive`
|
||||||
|
naar `~/umbrel/app-data/<app-id>/`. Alles wat daar staat belandt dus op de Umbrel én in de back-up.
|
||||||
|
Voor een gelaagd bewerkbestand is dat allebei zinloos: het apparaat kan er niets mee en de back-up
|
||||||
|
wordt er alleen groter van.
|
||||||
|
|
||||||
|
Dat de app-map niets mag bevatten dat umbreld niet nodig heeft, controleert
|
||||||
|
`tests/test_appstore_vorm.py` sinds 30-08-2026. Kwam je hier omdat die toets over een bestand van
|
||||||
|
jou klaagde: dit is de plek waar het hoort.
|
||||||
|
|
||||||
|
## Wat er wél in de app-map staat
|
||||||
|
|
||||||
|
Alleen het resultaat: `icon.png`. Dat bestand staat **niet** in de whitelist die umbreld bij een
|
||||||
|
update ververst, dus een nieuw icoon bereikt een bestaande installatie niet. Twee dingen volgen
|
||||||
|
daaruit, en de tweede wordt makkelijk vergeten:
|
||||||
|
|
||||||
|
- het manifest verwijst met een `icon:`-regel naar de rauwe URL in deze repo, en dát is wat het
|
||||||
|
dashboard van umbrelOS toont. Een nieuw icoon is daar dus meteen zichtbaar;
|
||||||
|
- de kop van de statuspagina laadt `icon.png` uit de gemounte app-map, en die blijft na een update
|
||||||
|
het oude plaatje tonen tot de app opnieuw geïnstalleerd wordt. Reken er niet op dat een nieuw
|
||||||
|
icoon overal in één keer doorkomt.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+54
@@ -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."
|
||||||
Generated
+616
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// De opdrachtregel lezen. Meer niet.
|
||||||
|
//
|
||||||
|
// Apart van cli.js omdat dit het enige deel van de opdrachtregel is dat te
|
||||||
|
// toetsen valt zonder iets te starten. Dezelfde scheiding als policy.js aan de
|
||||||
|
// relay-kant: pure functies aan de ene kant, alles wat schijf of netwerk raakt
|
||||||
|
// aan de andere.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const OPDRACHTEN = {
|
||||||
|
nieuw: { argumenten: ['naam'], uitleg: 'Maakt een nieuwe eigenaar met verse sleutels' },
|
||||||
|
lijst: { argumenten: [], uitleg: 'Toont de eigenaars in het register' },
|
||||||
|
schrijf: { argumenten: ['naam', 'label'], uitleg: 'Schrijft een blob weg voor deze eigenaar' },
|
||||||
|
lees: { argumenten: ['naam'], uitleg: 'Toont de blobs van deze eigenaar' },
|
||||||
|
test: { argumenten: ['naam'], uitleg: 'Test of de relay deze eigenaar toelaat, zonder te verbinden' },
|
||||||
|
spiegel: { argumenten: ['naam'], uitleg: 'Haalt deze eigenaar op in een lege database, via de relay' },
|
||||||
|
sync: { argumenten: ['naam'], uitleg: 'Verbindt en blijft draaien tot Ctrl-C' },
|
||||||
|
vergeet: { argumenten: ['naam'], uitleg: 'Haalt een eigenaar uit het register' },
|
||||||
|
ui: { argumenten: [], uitleg: 'Start het bedieningsvlak in de browser' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leest `process.argv.slice(2)`.
|
||||||
|
*
|
||||||
|
* Geeft `{fout}` in plaats van te gooien: de aanroeper drukt dat af met de
|
||||||
|
* gebruiksaanwijzing eronder, en dat leest beter dan een stack trace.
|
||||||
|
*
|
||||||
|
* Vlaggen zijn `--naam waarde` of `--naam=waarde`. Een losse `--naam` is `true`.
|
||||||
|
*/
|
||||||
|
export const leesArgumenten = (argv) => {
|
||||||
|
const losse = [];
|
||||||
|
const vlaggen = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const stuk = argv[i];
|
||||||
|
if (!stuk.startsWith('--')) {
|
||||||
|
losse.push(stuk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kaal = stuk.slice(2);
|
||||||
|
const isGelijk = kaal.indexOf('=');
|
||||||
|
if (isGelijk > 0) {
|
||||||
|
vlaggen[kaal.slice(0, isGelijk)] = kaal.slice(isGelijk + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const volgende = argv[i + 1];
|
||||||
|
if (volgende !== undefined && !volgende.startsWith('--')) {
|
||||||
|
vlaggen[kaal] = volgende;
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
vlaggen[kaal] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [opdracht, ...rest] = losse;
|
||||||
|
|
||||||
|
if (opdracht === undefined) return { fout: null, opdracht: null, waarden: {}, vlaggen };
|
||||||
|
|
||||||
|
const vorm = OPDRACHTEN[opdracht];
|
||||||
|
if (!vorm) return { fout: `Onbekende opdracht "${opdracht}".`, opdracht: null, waarden: {}, vlaggen };
|
||||||
|
|
||||||
|
if (rest.length < vorm.argumenten.length) {
|
||||||
|
const ontbreekt = vorm.argumenten.slice(rest.length).join(', ');
|
||||||
|
return { fout: `"${opdracht}" mist: ${ontbreekt}.`, opdracht, waarden: {}, vlaggen };
|
||||||
|
}
|
||||||
|
if (rest.length > vorm.argumenten.length) {
|
||||||
|
// Streng, en met opzet. `schrijf piet mijn label` ziet er goed uit en zou
|
||||||
|
// anders stil "mijn" als label nemen; dan sta je te kijken naar een blob die
|
||||||
|
// anders heet dan je typte.
|
||||||
|
return {
|
||||||
|
fout: `"${opdracht}" verwacht ${vorm.argumenten.length} argument(en), niet ${rest.length}. Gebruik aanhalingstekens om een label met spaties.`,
|
||||||
|
opdracht,
|
||||||
|
waarden: {},
|
||||||
|
vlaggen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const waarden = {};
|
||||||
|
vorm.argumenten.forEach((naam, plek) => {
|
||||||
|
waarden[naam] = rest[plek];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fout: null, opdracht, waarden, vlaggen };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gebruiksaanwijzing = () => {
|
||||||
|
const regels = ['Gebruik: node src/cli.js <opdracht> [argumenten]', ''];
|
||||||
|
for (const [naam, vorm] of Object.entries(OPDRACHTEN)) {
|
||||||
|
const argumenten = vorm.argumenten.map((a) => `<${a}>`).join(' ');
|
||||||
|
regels.push(` ${`${naam} ${argumenten}`.trim().padEnd(24)}${vorm.uitleg}`);
|
||||||
|
}
|
||||||
|
regels.push('');
|
||||||
|
regels.push('Vlaggen:');
|
||||||
|
regels.push(' --bytes <n> Omvang van de blob bij `schrijf` (standaard 64)');
|
||||||
|
regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`');
|
||||||
|
regels.push(' --wacht <n> Seconden wachten op de relay, bij `spiegel` (standaard 15)');
|
||||||
|
regels.push(' --poort <n> Poort van het bedieningsvlak bij `ui`');
|
||||||
|
regels.push('');
|
||||||
|
regels.push('Het adres van de relay komt uit .env; zie .env.sample.');
|
||||||
|
return regels.join('\n');
|
||||||
|
};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// De opdrachtregel van de testcliënt.
|
||||||
|
//
|
||||||
|
// Alles wat hier gebeurt kan ook in het bedieningsvlak (`ui`), en dat is de
|
||||||
|
// bedoeling: dit is dezelfde laag, alleen zonder browser. Handig om een proef te
|
||||||
|
// scripten en om te zien wat er misgaat als de pagina niet meewerkt.
|
||||||
|
//
|
||||||
|
// De opdrachten staan in argumenten.js, het register in register.js, en het
|
||||||
|
// praten met Evolu in client.js. Hier staat alleen de lijm plus wat er op het
|
||||||
|
// scherm komt.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { gebruiksaanwijzing, leesArgumenten } from './argumenten.js';
|
||||||
|
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
|
||||||
|
import { leesConfig } from './config.js';
|
||||||
|
import { klopAan } from './probe.js';
|
||||||
|
import { openRegister } from './register.js';
|
||||||
|
|
||||||
|
const config = leesConfig();
|
||||||
|
const register = openRegister(config.dataDir);
|
||||||
|
const databasesIn = join(config.dataDir, 'db');
|
||||||
|
|
||||||
|
/** Zoekt een eigenaar op of stopt met een nette melding. */
|
||||||
|
const haalEigenaarOp = (naam) => {
|
||||||
|
const regel = register.zoek(naam);
|
||||||
|
if (!regel) {
|
||||||
|
console.error(`Geen eigenaar "${naam}". \`lijst\` toont wie er wel zijn.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return { regel, owner: eigenaarUitMnemonic(regel.mnemonic) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Opent de opslag van een eigenaar, met of zonder relay. */
|
||||||
|
const openVoor = (regel, { metRelay }) =>
|
||||||
|
openStore({
|
||||||
|
directory: databasesIn,
|
||||||
|
owner: eigenaarUitMnemonic(regel.mnemonic),
|
||||||
|
relayUrl: metRelay ? config.relayUrl : null,
|
||||||
|
// Een defect in een worker betekent dat deze instantie stuk is, en de
|
||||||
|
// opdracht erna wacht dan op een antwoord dat nooit komt. Afkappen is hier
|
||||||
|
// dus vriendelijker dan doordraaien: een opdrachtregel die blijft hangen
|
||||||
|
// zegt niets, een foutmelding wel.
|
||||||
|
onDefect: (defect) => {
|
||||||
|
console.error('Defect uit een worker, de opdracht wordt afgebroken:');
|
||||||
|
console.error(defect);
|
||||||
|
process.exit(1);
|
||||||
|
},
|
||||||
|
// Wat Evolu wérkelijk opent, en niet wat wij hem meegaven. De `OwnerId` in
|
||||||
|
// die URL is wat de relay ziet en dus wie er toegelaten of geweigerd wordt.
|
||||||
|
onWebSocket: (m) => {
|
||||||
|
console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eisRelay = () => {
|
||||||
|
if (config.relayUrl) return;
|
||||||
|
console.error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const opdrachten = {
|
||||||
|
nieuw: async ({ naam }) => {
|
||||||
|
const owner = nieuweEigenaar();
|
||||||
|
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
|
||||||
|
console.log(`Eigenaar "${regel.naam}" aangemaakt.`);
|
||||||
|
console.log(` OwnerId ${regel.ownerId}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Dat OwnerId is wat je op de statuspagina van de app terugziet. De mnemonic staat');
|
||||||
|
console.log(`in ${register.pad} en wordt hier niet afgedrukt.`);
|
||||||
|
},
|
||||||
|
|
||||||
|
lijst: async () => {
|
||||||
|
const eigenaars = register.lijst();
|
||||||
|
if (eigenaars.length === 0) {
|
||||||
|
console.log('Nog geen eigenaars. `nieuw <naam>` maakt er een.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const breedte = Math.max(...eigenaars.map((e) => e.naam.length), 4);
|
||||||
|
console.log(`${'naam'.padEnd(breedte)} OwnerId`);
|
||||||
|
for (const eigenaar of eigenaars) {
|
||||||
|
console.log(`${eigenaar.naam.padEnd(breedte)} ${eigenaar.ownerId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
schrijf: async ({ naam, label }, vlaggen) => {
|
||||||
|
const { regel } = haalEigenaarOp(naam);
|
||||||
|
const bytes = Number.parseInt(vlaggen.bytes ?? '64', 10);
|
||||||
|
if (!Number.isInteger(bytes) || bytes < 1) {
|
||||||
|
console.error(`--bytes moet een positief geheel getal zijn, niet "${vlaggen.bytes}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Een herkenbaar patroon en geen willekeur: bij het teruglezen wil je kunnen
|
||||||
|
// zien dát het jouw bytes zijn, en niet alleen hoeveel het er waren.
|
||||||
|
const body = new Uint8Array(bytes);
|
||||||
|
for (let i = 0; i < bytes; i += 1) body[i] = i % 256;
|
||||||
|
|
||||||
|
// Standaard mét relay, want schrijven zonder verbinding zegt niets over de
|
||||||
|
// relay en dat is waarvoor dit gereedschap bestaat. Ontbreekt het adres, dan
|
||||||
|
// is dat een fout en geen stille lokale schrijfactie: anders denk je dat je
|
||||||
|
// iets beproefd hebt. Met `--lokaal` kies je er expliciet voor, en dat is
|
||||||
|
// precies wat de proef "toelaten en opnieuw verbinden" nodig heeft.
|
||||||
|
const lokaal = vlaggen.lokaal === true || vlaggen.lokaal === 'true';
|
||||||
|
if (!lokaal) eisRelay();
|
||||||
|
|
||||||
|
const store = await openVoor(regel, { metRelay: !lokaal });
|
||||||
|
try {
|
||||||
|
const geschreven = await store.schrijf(label, body);
|
||||||
|
console.log(`Geschreven: ${geschreven.id} (${bytes} bytes, label "${label}")`);
|
||||||
|
if (lokaal) {
|
||||||
|
console.log('Lokaal, dus niet naar de relay gestuurd.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Verbonden met ${store.transportUrl}`);
|
||||||
|
// De synchronisatie loopt asynchroon. Even wachten is hier eerlijker dan
|
||||||
|
// meteen afsluiten en doen alsof het weg is. Wat er daarna op de relay
|
||||||
|
// staat, zegt de statuspagina van de app en niet deze uitdraai.
|
||||||
|
await new Promise((klaar) => {
|
||||||
|
setTimeout(klaar, 2000);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await store.sluit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
lees: async ({ naam }) => {
|
||||||
|
const { regel } = haalEigenaarOp(naam);
|
||||||
|
const store = await openVoor(regel, { metRelay: false });
|
||||||
|
try {
|
||||||
|
const rijen = await store.lees();
|
||||||
|
if (rijen.length === 0) {
|
||||||
|
console.log(`"${naam}" heeft nog geen blobs.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${rijen.length} blob(s) voor "${naam}":`);
|
||||||
|
for (const rij of rijen) {
|
||||||
|
console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await store.sluit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
test: async ({ naam }) => {
|
||||||
|
eisRelay();
|
||||||
|
const { regel } = haalEigenaarOp(naam);
|
||||||
|
console.log(`Testen bij ${config.relayUrl} als ${regel.ownerId}`);
|
||||||
|
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
|
||||||
|
console.log(` status ${uitkomst.status ?? '-'}`);
|
||||||
|
console.log(` uitkomst ${uitkomst.uitleg}`);
|
||||||
|
if (!uitkomst.geaccepteerd && uitkomst.status === 401) {
|
||||||
|
console.log('');
|
||||||
|
console.log('Zet op de statuspagina van de app het venster voor nieuwe eigenaars open,');
|
||||||
|
console.log('of laat deze eigenaar daar alsnog toe.');
|
||||||
|
}
|
||||||
|
process.exit(uitkomst.geaccepteerd ? 0 : 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
spiegel: async ({ naam }, vlaggen) => {
|
||||||
|
// De enige proef die van de cliëntkant kan bewijzen dat er werkelijk iets op
|
||||||
|
// de relay staat. Dezelfde eigenaar, een lege database ernaast: alles wat
|
||||||
|
// hier binnenkomt heeft de heenreis en de terugreis gemaakt.
|
||||||
|
//
|
||||||
|
// Waarom dit nodig is: de cliënt is local-first, dus `lees` toont je altijd
|
||||||
|
// je eigen rijen, ook als de relay ze nooit gezien heeft. Wat de relay
|
||||||
|
// bewaart is van hier niet te zien; dit is de omweg eromheen.
|
||||||
|
eisRelay();
|
||||||
|
const { regel } = haalEigenaarOp(naam);
|
||||||
|
const seconden = Number.parseInt(vlaggen.wacht ?? '15', 10);
|
||||||
|
|
||||||
|
const tijdelijk = mkdtempSync(join(tmpdir(), 'relay-client-spiegel-'));
|
||||||
|
console.log(`"${naam}" opnieuw opgehaald in een lege database.`);
|
||||||
|
console.log(` ${tijdelijk}`);
|
||||||
|
console.log(` ${config.relayUrl}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const store = await openStore({
|
||||||
|
directory: tijdelijk,
|
||||||
|
owner: eigenaarUitMnemonic(regel.mnemonic),
|
||||||
|
relayUrl: config.relayUrl,
|
||||||
|
onWebSocket: (m) => { console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`); },
|
||||||
|
onDefect: (defect) => {
|
||||||
|
console.error('Defect uit een worker, de opdracht wordt afgebroken:');
|
||||||
|
console.error(defect);
|
||||||
|
process.exit(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Peilen tot het aantal een paar rondes stil blijft. Er is geen signaal
|
||||||
|
// "hier ben ik klaar": Evolu synchroniseert en blijft synchroniseren.
|
||||||
|
let vorige = -1;
|
||||||
|
let stil = 0;
|
||||||
|
for (let ronde = 0; ronde < seconden * 2; ronde += 1) {
|
||||||
|
await new Promise((klaar) => {
|
||||||
|
setTimeout(klaar, 500);
|
||||||
|
});
|
||||||
|
const nu = (await store.lees()).length;
|
||||||
|
if (nu === vorige && nu > 0) {
|
||||||
|
stil += 1;
|
||||||
|
if (stil >= 6) break;
|
||||||
|
} else {
|
||||||
|
stil = 0;
|
||||||
|
}
|
||||||
|
vorige = nu;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rijen = await store.lees();
|
||||||
|
if (rijen.length === 0) {
|
||||||
|
console.log('Er kwam niets terug.');
|
||||||
|
console.log('');
|
||||||
|
console.log('Dat kan twee dingen betekenen en de cliënt kan ze niet uit elkaar houden:');
|
||||||
|
console.log('de relay heeft niets van deze eigenaar, of hij was er nog niet klaar mee.');
|
||||||
|
console.log('Probeer `--wacht 60` en kijk anders op de statuspagina van de app.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${rijen.length} blob(s) terug van de relay:`);
|
||||||
|
for (const rij of rijen) {
|
||||||
|
console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await store.sluit();
|
||||||
|
// Eigen tijdelijke map, dus die ruimt deze opdracht zelf op. Er zit een
|
||||||
|
// database van een echte eigenaar in.
|
||||||
|
rmSync(tijdelijk, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sync: async ({ naam }) => {
|
||||||
|
eisRelay();
|
||||||
|
const { regel } = haalEigenaarOp(naam);
|
||||||
|
const store = await openVoor(regel, { metRelay: true });
|
||||||
|
console.log(`"${naam}" verbonden met ${store.transportUrl}`);
|
||||||
|
console.log('Ctrl-C om te stoppen.');
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
console.log('');
|
||||||
|
console.log('Afsluiten...');
|
||||||
|
await store.sluit();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blijven draaien. Een lopende verbinding is precies wat de proef "blokkeren
|
||||||
|
// tijdens een lopende verbinding" nodig heeft; zie PLAN.md §4c.
|
||||||
|
await new Promise(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
vergeet: async ({ naam }) => {
|
||||||
|
const weg = register.vergeet(naam);
|
||||||
|
console.log(`"${weg.naam}" is uit het register.`);
|
||||||
|
console.log('De lokale database blijft staan en op de relay verandert er niets.');
|
||||||
|
},
|
||||||
|
|
||||||
|
ui: async (_waarden, vlaggen) => {
|
||||||
|
const { startUi } = await import('./ui.js');
|
||||||
|
const poort = vlaggen.poort ? Number.parseInt(vlaggen.poort, 10) : config.uiPoort;
|
||||||
|
await startUi({ config: { ...config, uiPoort: poort }, register, databasesIn });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { fout, opdracht, waarden, vlaggen } = leesArgumenten(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (fout) {
|
||||||
|
console.error(fout);
|
||||||
|
console.error('');
|
||||||
|
console.error(gebruiksaanwijzing());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opdracht) {
|
||||||
|
console.log(gebruiksaanwijzing());
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await opdrachten[opdracht](waarden, vlaggen);
|
||||||
|
} catch (oorzaak) {
|
||||||
|
console.error(oorzaak.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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]();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Het bedieningsvlak van de testclient.
|
||||||
|
|
||||||
|
Engels op het scherm zou hier misstaan: dit is gereedschap voor de eigen
|
||||||
|
werkbank en niet iets wat een bezoeker van de app store ziet. De taalgrens in
|
||||||
|
Werkafspraken.md loopt langs "ziet de gebruiker van de app dit", en dat is hier nee.
|
||||||
|
|
||||||
|
Geen framework en geen bouwstap, net als de statuspagina's van de twee apps.
|
||||||
|
-->
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Relay-testclient</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--grond: #f6f6f4;
|
||||||
|
--kaart: #ffffff;
|
||||||
|
--rand: #e2e2dd;
|
||||||
|
--tekst: #1b1b19;
|
||||||
|
--zacht: #6b6b64;
|
||||||
|
--accent: #b45309;
|
||||||
|
--goed: #15803d;
|
||||||
|
--fout: #b91c1c;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--grond: #17171a;
|
||||||
|
--kaart: #1f1f23;
|
||||||
|
--rand: #33333a;
|
||||||
|
--tekst: #ececea;
|
||||||
|
--zacht: #9a9a94;
|
||||||
|
--accent: #f59e0b;
|
||||||
|
--goed: #4ade80;
|
||||||
|
--fout: #f87171;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px 20px 64px;
|
||||||
|
background: var(--grond);
|
||||||
|
color: var(--tekst);
|
||||||
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
main { max-width: 940px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--zacht); font-weight: 600; }
|
||||||
|
.kop { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline;
|
||||||
|
margin-bottom: 20px; color: var(--zacht); font-size: 13px; }
|
||||||
|
.kaart { background: var(--kaart); border: 1px solid var(--rand);
|
||||||
|
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; }
|
||||||
|
.rij { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
|
||||||
|
.naam { font-weight: 600; font-size: 16px; }
|
||||||
|
.id { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||||
|
font-size: 12px; color: var(--zacht); word-break: break-all; }
|
||||||
|
.stip { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--zacht); margin-right: 6px; vertical-align: 1px; }
|
||||||
|
.stip.aan { background: var(--goed); }
|
||||||
|
.duw { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
button, input {
|
||||||
|
font: inherit; border-radius: 7px; border: 1px solid var(--rand);
|
||||||
|
background: var(--kaart); color: var(--tekst); padding: 5px 11px;
|
||||||
|
}
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||||
|
button:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
input { width: 8em; }
|
||||||
|
input.breed { width: 12em; }
|
||||||
|
.leeg, .melding { color: var(--zacht); font-size: 14px; }
|
||||||
|
.melding.fout { color: var(--fout); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
|
||||||
|
td { padding: 3px 8px 3px 0; border-top: 1px solid var(--rand); }
|
||||||
|
td.getal { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
#log { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||||
|
font-size: 12px; max-height: 260px; overflow-y: auto; }
|
||||||
|
#log div { padding: 2px 0; border-top: 1px solid var(--rand); }
|
||||||
|
#log div:first-child { border-top: 0; }
|
||||||
|
#log time { color: var(--zacht); margin-right: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Relay-testclient</h1>
|
||||||
|
<div class="kop">
|
||||||
|
<span>Relay: <b id="relay">…</b></span>
|
||||||
|
<span id="waarschuwing" class="melding fout" hidden>
|
||||||
|
Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Nieuwe eigenaar</h2>
|
||||||
|
<div class="kaart">
|
||||||
|
<div class="rij">
|
||||||
|
<input id="nieuwNaam" class="breed" placeholder="naam" maxlength="40" />
|
||||||
|
<button id="nieuwKnop">Aanmaken</button>
|
||||||
|
<span id="nieuwMelding" class="melding"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Eigenaars</h2>
|
||||||
|
<div id="eigenaars"></div>
|
||||||
|
|
||||||
|
<h2>Log</h2>
|
||||||
|
<div class="kaart" id="log"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
let bezig = false;
|
||||||
|
|
||||||
|
// Wat er als laatste getekend is. De lijst wordt in zijn geheel opnieuw
|
||||||
|
// opgebouwd, dus zonder deze vergelijking zou elke drie seconden het veld
|
||||||
|
// met de omvang leeggaan terwijl je erin typt, en zou een knop onder je
|
||||||
|
// muis vervangen worden.
|
||||||
|
let laatsteStaat = '';
|
||||||
|
|
||||||
|
// De ingetypte omvang per eigenaar, zodat die een hertekening overleeft.
|
||||||
|
const omvangPer = new Map();
|
||||||
|
|
||||||
|
const roep = async (actie, waarden) => {
|
||||||
|
const antwoord = await fetch(`/api/${actie}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(waarden ?? {}),
|
||||||
|
});
|
||||||
|
const uit = await antwoord.json();
|
||||||
|
if (!antwoord.ok) throw new Error(uit.fout ?? 'Er ging iets mis.');
|
||||||
|
return uit;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tijd = (iso) => (iso ? new Date(iso).toLocaleTimeString('nl-NL') : '');
|
||||||
|
|
||||||
|
const knop = (tekst, aan) => {
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.textContent = tekst;
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
if (bezig) return;
|
||||||
|
bezig = true;
|
||||||
|
el.disabled = true;
|
||||||
|
try {
|
||||||
|
await aan();
|
||||||
|
} catch (oorzaak) {
|
||||||
|
alert(oorzaak.message);
|
||||||
|
} finally {
|
||||||
|
bezig = false;
|
||||||
|
el.disabled = false;
|
||||||
|
await ververs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
const kaartVoor = (eigenaar) => {
|
||||||
|
const kaart = document.createElement('div');
|
||||||
|
kaart.className = 'kaart';
|
||||||
|
|
||||||
|
const rij = document.createElement('div');
|
||||||
|
rij.className = 'rij';
|
||||||
|
|
||||||
|
const naam = document.createElement('span');
|
||||||
|
naam.className = 'naam';
|
||||||
|
naam.innerHTML = `<span class="stip${eigenaar.verbonden ? ' aan' : ''}"></span>`;
|
||||||
|
naam.append(eigenaar.naam);
|
||||||
|
rij.append(naam);
|
||||||
|
|
||||||
|
const toestand = document.createElement('span');
|
||||||
|
toestand.className = 'melding';
|
||||||
|
toestand.textContent = eigenaar.verbonden
|
||||||
|
? `${eigenaar.metRelay ? 'verbonden' : 'lokaal open'} sinds ${tijd(eigenaar.sinds)}${
|
||||||
|
eigenaar.blobs === null ? '' : ` · ${eigenaar.blobs} blob(s)`
|
||||||
|
}`
|
||||||
|
: 'niet verbonden';
|
||||||
|
rij.append(toestand);
|
||||||
|
|
||||||
|
if (eigenaar.test) {
|
||||||
|
const test = document.createElement('span');
|
||||||
|
test.className = eigenaar.test.geaccepteerd ? 'melding' : 'melding fout';
|
||||||
|
test.textContent = `getest ${tijd(eigenaar.test.tijd)}: ${eigenaar.test.status ?? '-'} ${eigenaar.test.uitleg}`;
|
||||||
|
rij.append(test);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duw = document.createElement('div');
|
||||||
|
duw.className = 'duw';
|
||||||
|
|
||||||
|
const omvang = document.createElement('input');
|
||||||
|
omvang.value = omvangPer.get(eigenaar.naam) ?? '64';
|
||||||
|
omvang.title = 'Omvang van de blob in bytes';
|
||||||
|
omvang.style.width = '6em';
|
||||||
|
omvang.addEventListener('input', () => omvangPer.set(eigenaar.naam, omvang.value));
|
||||||
|
|
||||||
|
// Verbinden gaat uit zodra een test zegt dat deze eigenaar geweigerd
|
||||||
|
// wordt. Niet uit netheid: Evolu blijft bij een weigering eindeloos
|
||||||
|
// opnieuw proberen, en élke poging is een regel in de weigerlijst van de
|
||||||
|
// relay. Onbekend telt als toegestaan, want anders moet je eerst testen
|
||||||
|
// om iets te mogen, en dat is zelf ook een poging.
|
||||||
|
const verbindKnop = eigenaar.verbonden
|
||||||
|
? knop('Verbreken', () => roep('verbreek', { naam: eigenaar.naam }))
|
||||||
|
: knop('Verbinden', () => roep('verbind', { naam: eigenaar.naam }));
|
||||||
|
|
||||||
|
if (!eigenaar.verbonden && !eigenaar.magVerbinden) {
|
||||||
|
verbindKnop.disabled = true;
|
||||||
|
verbindKnop.title = 'De relay weigert deze eigenaar. Laat hem toe op de statuspagina van de app en test opnieuw.';
|
||||||
|
}
|
||||||
|
|
||||||
|
duw.append(
|
||||||
|
// Geen melding in een dialoogvenster: de uitkomst komt naast de naam te
|
||||||
|
// staan en in het log, en die blijven staan. Een dialoog moet je
|
||||||
|
// wegklikken en is daarna weg.
|
||||||
|
knop('Testen', () => roep('test', { naam: eigenaar.naam })),
|
||||||
|
verbindKnop,
|
||||||
|
omvang,
|
||||||
|
knop('Blob schrijven', () =>
|
||||||
|
roep('schrijf', { naam: eigenaar.naam, label: `blob ${tijd(new Date().toISOString())}`, bytes: omvang.value }),
|
||||||
|
),
|
||||||
|
knop('Lezen', async () => {
|
||||||
|
const uit = await roep('lees', { naam: eigenaar.naam });
|
||||||
|
toonRijen(kaart, uit.rijen);
|
||||||
|
}),
|
||||||
|
knop('Vergeten', async () => {
|
||||||
|
if (!confirm(`"${eigenaar.naam}" uit het register halen? De lokale database blijft staan.`)) return;
|
||||||
|
await roep('vergeet', { naam: eigenaar.naam });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
rij.append(duw);
|
||||||
|
kaart.append(rij);
|
||||||
|
|
||||||
|
const id = document.createElement('div');
|
||||||
|
id.className = 'id';
|
||||||
|
id.textContent = eigenaar.ownerId;
|
||||||
|
kaart.append(id);
|
||||||
|
|
||||||
|
return kaart;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toonRijen = (kaart, rijen) => {
|
||||||
|
kaart.querySelector('table')?.remove();
|
||||||
|
const tabel = document.createElement('table');
|
||||||
|
if (rijen.length === 0) {
|
||||||
|
const cel = tabel.insertRow().insertCell();
|
||||||
|
cel.className = 'leeg';
|
||||||
|
cel.textContent = 'Nog geen blobs.';
|
||||||
|
}
|
||||||
|
for (const rij of rijen) {
|
||||||
|
const tr = tabel.insertRow();
|
||||||
|
tr.insertCell().textContent = rij.label;
|
||||||
|
const bytes = tr.insertCell();
|
||||||
|
bytes.className = 'getal';
|
||||||
|
bytes.textContent = `${rij.bytes} bytes`;
|
||||||
|
const id = tr.insertCell();
|
||||||
|
id.className = 'id';
|
||||||
|
id.textContent = rij.id;
|
||||||
|
}
|
||||||
|
kaart.append(tabel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ververs = async (altijd = false) => {
|
||||||
|
const ruw = await (await fetch('/api/status')).text();
|
||||||
|
if (!altijd && ruw === laatsteStaat) return;
|
||||||
|
laatsteStaat = ruw;
|
||||||
|
const staat = JSON.parse(ruw);
|
||||||
|
|
||||||
|
$('relay').textContent = staat.relayUrl ?? 'niet ingesteld';
|
||||||
|
$('waarschuwing').hidden = Boolean(staat.relayUrl);
|
||||||
|
|
||||||
|
const lijst = $('eigenaars');
|
||||||
|
lijst.textContent = '';
|
||||||
|
if (staat.eigenaars.length === 0) {
|
||||||
|
const leeg = document.createElement('div');
|
||||||
|
leeg.className = 'kaart leeg';
|
||||||
|
leeg.textContent = 'Nog geen eigenaars. Maak er hierboven een aan.';
|
||||||
|
lijst.append(leeg);
|
||||||
|
}
|
||||||
|
for (const eigenaar of staat.eigenaars) lijst.append(kaartVoor(eigenaar));
|
||||||
|
|
||||||
|
const log = $('log');
|
||||||
|
log.textContent = '';
|
||||||
|
if (staat.log.length === 0) {
|
||||||
|
log.className = 'kaart leeg';
|
||||||
|
log.textContent = 'Nog niets gebeurd.';
|
||||||
|
} else {
|
||||||
|
log.className = 'kaart';
|
||||||
|
for (const regel of staat.log) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const t = document.createElement('time');
|
||||||
|
t.textContent = tijd(regel.tijd);
|
||||||
|
el.append(t, regel.tekst);
|
||||||
|
log.append(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$('nieuwKnop').addEventListener('click', async () => {
|
||||||
|
const naam = $('nieuwNaam').value.trim();
|
||||||
|
const melding = $('nieuwMelding');
|
||||||
|
melding.className = 'melding';
|
||||||
|
melding.textContent = '';
|
||||||
|
try {
|
||||||
|
const uit = await roep('nieuw', { naam });
|
||||||
|
$('nieuwNaam').value = '';
|
||||||
|
melding.textContent = `"${uit.naam}" aangemaakt.`;
|
||||||
|
await ververs();
|
||||||
|
} catch (oorzaak) {
|
||||||
|
melding.className = 'melding fout';
|
||||||
|
melding.textContent = oorzaak.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('nieuwNaam').addEventListener('keydown', (gebeurtenis) => {
|
||||||
|
if (gebeurtenis.key === 'Enter') $('nieuwKnop').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
void ververs();
|
||||||
|
// Verversen terwijl je met een knop bezig bent zou de kaart onder je muis
|
||||||
|
// vervangen, dus dat wordt overgeslagen.
|
||||||
|
setInterval(() => {
|
||||||
|
if (!bezig) void ververs();
|
||||||
|
}, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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(() => {});
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// release-bump.mjs: zet een nieuwe image-tag met digest in de compose van een app
|
||||||
|
// en verhoogt `version` in zijn manifest.
|
||||||
|
//
|
||||||
|
// Draaien: node tools/release-bump.mjs <app-id> <image-versie> <digest>
|
||||||
|
// Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...
|
||||||
|
//
|
||||||
|
// WAAROM DIT BESTAAT. De release-pijplijn in de AssetApp-repo bouwt, duwt en leest
|
||||||
|
// de digest terug, maar werkt deze repo bewust NIET bij: een token dat daar staat en
|
||||||
|
// hier mag schrijven, kan alles naar de store duwen die umbreld anoniem kloont. Wat
|
||||||
|
// overbleef was overtypwerk, en juist daar zit de stille fout: een OUDE digest onder
|
||||||
|
// een NIEUWE tag levert de oude image, zonder melding. Dit script vervangt de hele
|
||||||
|
// `image:`-regel, dus die combinatie kan niet meer ontstaan.
|
||||||
|
//
|
||||||
|
// Lokaal draaien en zelf de diff lezen is het ontwerp en geen tussenoplossing. Het
|
||||||
|
// script pusht niets.
|
||||||
|
//
|
||||||
|
// DE TWEE NUMMERS ZIJN NIET HETZELFDE. De tag is de versie van de IMAGE, `version`
|
||||||
|
// in het manifest die van de APP, en die tweede moet bij élke wijziging omhoog, ook
|
||||||
|
// als alleen de compose verandert, anders rolt umbrelOS niets uit. Daarom kiest dit
|
||||||
|
// script hem expliciet en drukt af wat hij koos.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const HOST = 'sc.kamenier-hamer.nl';
|
||||||
|
|
||||||
|
// ── De twee herschrijvingen: puur, want daar gaan de tests over ────────────────
|
||||||
|
|
||||||
|
const DIGEST_VORM = /^sha256:[0-9a-f]{64}$/;
|
||||||
|
const VERSIE_VORM = /^\d+\.\d+\.\d+$/;
|
||||||
|
|
||||||
|
// Eén `image:`-regel van déze host. Een regel van een andere host blijft staan: een
|
||||||
|
// app mag een image van buiten gebruiken en die hoort hier niet mee te verschuiven.
|
||||||
|
function imageRegels(tekst) {
|
||||||
|
const regels = tekst.split(/\r?\n/);
|
||||||
|
const treffers = [];
|
||||||
|
regels.forEach((regel, i) => {
|
||||||
|
const m = regel.match(new RegExp(`^(\\s*image:\\s*)(${HOST}/[^\\s:@]+)(?::[^\\s@]+)?(?:@sha256:[0-9a-f]+)?\\s*$`));
|
||||||
|
if (m) treffers.push({ i, inspring: m[1], naam: m[2] });
|
||||||
|
});
|
||||||
|
return treffers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vervangt de HELE regel en plakt er niets bij. Dat is de kern: een oude digest kan
|
||||||
|
// zo niet blijven staan. Meerdere containers uit hetzelfde image gaan samen mee, want
|
||||||
|
// twee versies naast elkaar in één compose is nooit de bedoeling.
|
||||||
|
export function herschrijfImage(tekst, versie, digest) {
|
||||||
|
if (!VERSIE_VORM.test(versie)) throw new Error(`versie heeft niet de vorm x.y.z: ${versie}`);
|
||||||
|
if (!DIGEST_VORM.test(digest)) throw new Error(`digest heeft niet de vorm sha256: plus 64 hex: ${digest}`);
|
||||||
|
|
||||||
|
const treffers = imageRegels(tekst);
|
||||||
|
if (treffers.length === 0) throw new Error(`geen image-regel van ${HOST} gevonden`);
|
||||||
|
|
||||||
|
const namen = [...new Set(treffers.map((t) => t.naam))];
|
||||||
|
if (namen.length > 1) throw new Error(`meerdere eigen images in één compose: ${namen.join(', ')}`);
|
||||||
|
|
||||||
|
const regels = tekst.split(/\r?\n/);
|
||||||
|
for (const t of treffers) regels[t.i] = `${t.inspring}${t.naam}:${versie}@${digest}`;
|
||||||
|
return { tekst: regels.join('\n'), naam: namen[0], aantal: treffers.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function huidigeAppVersie(manifest) {
|
||||||
|
const m = manifest.match(/^version:\s*"?(\d+\.\d+\.\d+)"?\s*$/m);
|
||||||
|
if (!m) throw new Error('geen `version:` met de vorm x.y.z in het manifest');
|
||||||
|
return m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoger is hoger, per deel en niet als tekst: "0.10.0" hoort boven "0.9.0" te staan.
|
||||||
|
export function isHoger(a, b) {
|
||||||
|
const [a1, a2, a3] = a.split('.').map(Number);
|
||||||
|
const [b1, b2, b3] = b.split('.').map(Number);
|
||||||
|
return a1 !== b1 ? a1 > b1 : a2 !== b2 ? a2 > b2 : a3 > b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// De app-versie is niet de image-versie. Ligt de image erboven, dan is gelijktrekken
|
||||||
|
// het duidelijkst; ligt hij eronder of gelijk, dan is alleen de patch omhoog genoeg om
|
||||||
|
// umbrelOS te laten uitrollen. Beide gevallen worden afgedrukt, want dit is precies het
|
||||||
|
// soort keuze dat je een maand later niet meer terugziet.
|
||||||
|
export function kiesAppVersie(huidig, imageVersie) {
|
||||||
|
if (isHoger(imageVersie, huidig)) return imageVersie;
|
||||||
|
const [groot, klein, patch] = huidig.split('.').map(Number);
|
||||||
|
return `${groot}.${klein}.${patch + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function herschrijfVersie(manifest, nieuw) {
|
||||||
|
if (!VERSIE_VORM.test(nieuw)) throw new Error(`versie heeft niet de vorm x.y.z: ${nieuw}`);
|
||||||
|
const regels = manifest.split(/\r?\n/);
|
||||||
|
const i = regels.findIndex((r) => /^version:\s*"?\d+\.\d+\.\d+"?\s*$/.test(r));
|
||||||
|
if (i === -1) throw new Error('geen `version:` met de vorm x.y.z in het manifest');
|
||||||
|
regels[i] = `version: "${nieuw}"`;
|
||||||
|
return regels.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── De controle bij het register ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Hoort die digest wel bij die tag? Dat is de enige vraag die dit script zelf niet
|
||||||
|
// kan beantwoorden, en het is de vraag waar de stille fout zit. Anoniem, want zo
|
||||||
|
// haalt umbreld hem straks ook: faalt dit met een 401, dan staat het pakket op privé
|
||||||
|
// en zou de installatie op het apparaat struikelen.
|
||||||
|
const MEDIA = [
|
||||||
|
'application/vnd.oci.image.index.v1+json',
|
||||||
|
'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.v2+json',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
export async function digestBijTag(naam, versie) {
|
||||||
|
const pad = naam.slice(HOST.length + 1);
|
||||||
|
const url = `https://${HOST}/v2/${pad}/manifests/${versie}`;
|
||||||
|
let antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA } });
|
||||||
|
|
||||||
|
// Het register vraagt ook voor een openbaar pakket een token, en dat token haal je
|
||||||
|
// zonder inloggegevens op. Slaagt dát niet, dan is het pakket niet openbaar.
|
||||||
|
if (antwoord.status === 401) {
|
||||||
|
const uitdaging = antwoord.headers.get('www-authenticate') || '';
|
||||||
|
const veld = (naam) => (uitdaging.match(new RegExp(`${naam}="([^"]+)"`)) || [])[1];
|
||||||
|
const realm = veld('realm');
|
||||||
|
if (!realm) throw new Error(`register vraagt om inloggegevens en zegt niet waar: ${uitdaging}`);
|
||||||
|
const tokenUrl = new URL(realm);
|
||||||
|
if (veld('service')) tokenUrl.searchParams.set('service', veld('service'));
|
||||||
|
tokenUrl.searchParams.set('scope', veld('scope') || `repository:${pad}:pull`);
|
||||||
|
const tokenAntwoord = await fetch(tokenUrl);
|
||||||
|
if (!tokenAntwoord.ok) throw new Error(`anoniem token weigeren (${tokenAntwoord.status}): staat het pakket op privé?`);
|
||||||
|
const { token } = await tokenAntwoord.json();
|
||||||
|
antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA, Authorization: `Bearer ${token}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (antwoord.status === 404) throw new Error(`tag ${versie} bestaat niet in het register`);
|
||||||
|
if (!antwoord.ok) throw new Error(`register antwoordt ${antwoord.status} op ${url}`);
|
||||||
|
const digest = antwoord.headers.get('docker-content-digest');
|
||||||
|
if (!digest) throw new Error('register geeft geen docker-content-digest terug');
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── De aanroep ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// `process.exitCode` en NOOIT `process.exit()`: die tweede kapt af terwijl de fetch
|
||||||
|
// naar het register nog open staat, en op Windows sterft Node dan in libuv met code 127
|
||||||
|
// in plaats van de code die je bedoelde. Zo eindigt een nette weigering als een crash.
|
||||||
|
|
||||||
|
async function main(argv) {
|
||||||
|
const vlaggen = argv.filter((a) => a.startsWith('--'));
|
||||||
|
const [app, versie, digest] = argv.filter((a) => !a.startsWith('--'));
|
||||||
|
if (!app || !versie || !digest) {
|
||||||
|
console.error('Gebruik: node tools/release-bump.mjs <app-id> <image-versie> <digest> [--offline]');
|
||||||
|
console.error('Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...');
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composePad = join(app, 'docker-compose.yml');
|
||||||
|
const manifestPad = join(app, 'umbrel-app.yml');
|
||||||
|
const compose = readFileSync(composePad, 'utf8');
|
||||||
|
const manifest = readFileSync(manifestPad, 'utf8');
|
||||||
|
|
||||||
|
const { tekst: nieuweCompose, naam, aantal } = herschrijfImage(compose, versie, digest);
|
||||||
|
|
||||||
|
if (!vlaggen.includes('--offline')) {
|
||||||
|
const echt = await digestBijTag(naam, versie);
|
||||||
|
if (echt !== digest) {
|
||||||
|
console.error(`De digest hoort NIET bij tag ${versie}.`);
|
||||||
|
console.error(` gegeven: ${digest}`);
|
||||||
|
console.error(` register: ${echt}`);
|
||||||
|
console.error('Er is niets gewijzigd. Dit is precies de verwisseling die stil de oude image oplevert.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const huidig = huidigeAppVersie(manifest);
|
||||||
|
const appVersie = kiesAppVersie(huidig, versie);
|
||||||
|
const nieuwManifest = herschrijfVersie(manifest, appVersie);
|
||||||
|
|
||||||
|
writeFileSync(composePad, nieuweCompose);
|
||||||
|
writeFileSync(manifestPad, nieuwManifest);
|
||||||
|
|
||||||
|
console.log(`${composePad}: ${naam}:${versie}@${digest}${aantal > 1 ? ` (${aantal} regels)` : ''}`);
|
||||||
|
console.log(`${manifestPad}: version ${huidig} → ${appVersie}`);
|
||||||
|
console.log(vlaggen.includes('--offline') ? 'Digest NIET gecontroleerd (--offline).' : 'Digest komt overeen met wat het register onder die tag heeft.');
|
||||||
|
console.log('Niets gepusht. Lees de diff en commit zelf.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pathToFileURL` en geen zelfgebouwde `file://`-string: op Windows begint die met een
|
||||||
|
// schijfletter en dan komt er één schuine streep te weinig uit, waardoor dit bestand
|
||||||
|
// als module wél laadt maar nooit iets doet.
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
main(process.argv.slice(2)).catch((fout) => {
|
||||||
|
console.error(String(fout.message || fout));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Maakt van een statuspagina een versie die je in een browser kunt openen.
|
||||||
|
//
|
||||||
|
// Draaien:
|
||||||
|
//
|
||||||
|
// node tools/voorbeeldpagina.mjs
|
||||||
|
// node tools/voorbeeldpagina.mjs whatsnext-electrum-gate
|
||||||
|
//
|
||||||
|
// Het resultaat komt in `voorbeeld/<app>.html` en die map is gitignored.
|
||||||
|
//
|
||||||
|
// ── Waarom dit bestaat ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Een statuspagina is niet zomaar te openen. Twee dingen staan in de weg, en ze
|
||||||
|
// zijn allebei fundamenteel en niet op te lossen door er anders naar te kijken:
|
||||||
|
//
|
||||||
|
// 1. in een `index.html.template` staan accolade-variabelen die umbreld invult.
|
||||||
|
// Onopgelost staat er letterlijk "v${APP_VERSION}" in de kop. Een pagina die
|
||||||
|
// in een eigen image zit heeft dit niet meer, maar haalt de versie dan uit
|
||||||
|
// status.json, dus zonder agent staat er niets;
|
||||||
|
// 2. de pagina haalt al zijn gegevens bij een agent die alleen in de app bestaat.
|
||||||
|
// Zonder die agent zie je één foutmelding en verder een pagina vol "unknown":
|
||||||
|
// geen lijsten, geen teller, geen knoppen.
|
||||||
|
//
|
||||||
|
// Dit script vult het eerste in en maakt het tweede na. Wat je dan ziet is de
|
||||||
|
// opmaak met plausibele gegevens erin, en dat is genoeg om te beoordelen of de
|
||||||
|
// twee apps naast elkaar hetzelfde ontwerp zijn.
|
||||||
|
//
|
||||||
|
// ── Wat het NIET is ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Geen test en geen bewijs. De gegevens zijn verzonnen, dus dit zegt niets over
|
||||||
|
// of de pagina de echte status juist weergeeft; dat blijft handwerk op het
|
||||||
|
// apparaat. Wat er wél mee te vinden is: alles wat met opmaak te maken heeft, en
|
||||||
|
// dat is precies waar deze pagina's steeds op aangepast worden.
|
||||||
|
//
|
||||||
|
// Voor de fouten die je niet ziet staan er twee toetsen: `test_paginas_parsen.mjs`
|
||||||
|
// (loopt het script) en de dollarteken-toets in `test_appstore_vorm.py` (blijft
|
||||||
|
// het script heel na de invulling).
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO = join(HIER, '..');
|
||||||
|
const UIT = join(REPO, 'voorbeeld');
|
||||||
|
|
||||||
|
const app = process.argv[2] || 'whatsnext-evolu-relay';
|
||||||
|
|
||||||
|
// De pagina staat op een van twee plekken: als *.template in de app-map, of als
|
||||||
|
// gewoon bestand in het bouwrecept onder tools/ als hij in een eigen image zit.
|
||||||
|
// Electrum Gate is sinds 0.1.0 het tweede geval. Alleen de eerste gaat door
|
||||||
|
// envsubst; voor de tweede is de invulling hieronder een lege stap.
|
||||||
|
const template = join(REPO, app, 'index.html.template');
|
||||||
|
const kort = app.includes('-') ? app.slice(app.indexOf('-') + 1) : app;
|
||||||
|
const inImage = join(REPO, 'tools', kort, 'index.html');
|
||||||
|
const bron = existsSync(template) ? template : inImage;
|
||||||
|
|
||||||
|
if (!existsSync(bron)) {
|
||||||
|
console.error(`FOUT: geen pagina gevonden op ${template} of ${inImage}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// De variabelen die umbreld invult. Alleen wat de pagina's gebruiken; de rest zou
|
||||||
|
// op het apparaat leeg worden en dat gebeurt hieronder ook.
|
||||||
|
const WAARDEN = {
|
||||||
|
APP_VERSION: leesVersie(app),
|
||||||
|
};
|
||||||
|
|
||||||
|
function leesVersie(appId) {
|
||||||
|
const manifest = readFileSync(join(REPO, appId, 'umbrel-app.yml'), 'utf8');
|
||||||
|
for (const regel of manifest.split('\n')) {
|
||||||
|
if (regel.startsWith('version:')) {
|
||||||
|
return regel.split(':')[1].trim().replace(/['"]/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── De nagemaakte status van Evolu Relay ──────────────────────────────────────
|
||||||
|
// Met opzet niet de gelukkige gevallen alleen: er staat een eigenaar met en zonder
|
||||||
|
// label, een geblokkeerde, twee geweigerde pogingen waarvan één met veel pogingen,
|
||||||
|
// en de teller loopt. Dat is de drukste toestand die de pagina kan hebben, en
|
||||||
|
// daarin vind je de opmaakfouten.
|
||||||
|
function relayStatus(nu) {
|
||||||
|
const iso = (msVanaf) => new Date(nu + msVanaf).toISOString();
|
||||||
|
return {
|
||||||
|
version: leesVersie('whatsnext-evolu-relay'),
|
||||||
|
relay: { reachable: true, checked: iso(-5000), publicPort: 3852 },
|
||||||
|
owners: {
|
||||||
|
problem: null,
|
||||||
|
learning: true,
|
||||||
|
learningUntil: iso(97000),
|
||||||
|
learningSecondsLeft: 97,
|
||||||
|
allowed: [
|
||||||
|
{
|
||||||
|
id: 'k7Qw2mZp9RtY4bVn6XsL1cHgJdFaEuOi',
|
||||||
|
allowed: true,
|
||||||
|
label: 'Trezor Suite, hoofdwallet',
|
||||||
|
firstSeen: iso(-86400000 * 2),
|
||||||
|
lastSeen: iso(-120000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p3Ne8UyTr5WqAzXc2VbNm9KlJhGfDsAq',
|
||||||
|
allowed: true,
|
||||||
|
label: null,
|
||||||
|
firstSeen: iso(-3600000),
|
||||||
|
lastSeen: iso(-45000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blocked: [
|
||||||
|
{
|
||||||
|
id: 'zZ9YyXxWwVvUuTtSsRrQqPpOoNnMmLlK',
|
||||||
|
allowed: false,
|
||||||
|
label: 'Oude telefoon',
|
||||||
|
firstSeen: iso(-86400000 * 9),
|
||||||
|
lastSeen: iso(-86400000 * 3),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected: [
|
||||||
|
{
|
||||||
|
id: 'aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV',
|
||||||
|
label: null,
|
||||||
|
firstSeen: iso(-1800000),
|
||||||
|
lastSeen: iso(-30000),
|
||||||
|
attempts: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'QqWwEeRrTtYyUuIiOoPpAaSsDdFfGgHh',
|
||||||
|
label: 'Laptop van de buren?',
|
||||||
|
firstSeen: iso(-600000),
|
||||||
|
lastSeen: iso(-600000),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
database: { bytes: 2374144, modified: iso(-120000) },
|
||||||
|
pendingCommand: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = readFileSync(bron, 'utf8');
|
||||||
|
|
||||||
|
// Doet na wat envsubst doet: bekende variabelen krijgen hun waarde, onbekende
|
||||||
|
// worden leeg. Dat laatste is geen slordigheid maar het gedrag op het apparaat.
|
||||||
|
html = html.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (heel, naam) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(WAARDEN, naam) ? WAARDEN[naam] : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Het icoon staat naast de template en niet naast het resultaat.
|
||||||
|
html = html.replace(
|
||||||
|
/src="icon\.png"/g,
|
||||||
|
`src="${join(REPO, app, 'icon.png').replace(/\\/g, '/')}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// De agent nabootsen. Vóór het eigen script van de pagina, want dat begint meteen
|
||||||
|
// met een ronde. Alleen voor Evolu Relay; Electrum Gate leest een ander bestand en
|
||||||
|
// daarvoor is dit script (nog) niet uitgebreid, dus die pagina toont zijn eigen
|
||||||
|
// melding dat er geen gegevens zijn. Dat is genoeg voor de kop en de dialoog.
|
||||||
|
if (app === 'whatsnext-evolu-relay') {
|
||||||
|
const stub = [
|
||||||
|
'<script>',
|
||||||
|
'(function () {',
|
||||||
|
` var status = ${JSON.stringify(relayStatus(Date.now()))};`,
|
||||||
|
' window.fetch = function (url, opties) {',
|
||||||
|
" if (String(url).indexOf('status') !== -1) {",
|
||||||
|
' return Promise.resolve({ ok: true, json: function () { return Promise.resolve(status); } });',
|
||||||
|
' }',
|
||||||
|
' return Promise.resolve({ ok: true, json: function () { return Promise.resolve({}); } });',
|
||||||
|
' };',
|
||||||
|
'})();',
|
||||||
|
'</script>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const anker = '<script>\n(function () {';
|
||||||
|
if (html.indexOf(anker) === -1) {
|
||||||
|
// Hard falen en niet stil doorgaan: zonder de nabootsing is het resultaat een
|
||||||
|
// pagina vol "unknown", en dan denk je dat je naar de opmaak kijkt terwijl je
|
||||||
|
// naar een foutmelding kijkt.
|
||||||
|
console.error('FOUT: het ankerpunt voor het eigen script is niet gevonden');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
html = html.replace(anker, `${stub}\n${anker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(UIT, { recursive: true });
|
||||||
|
const doel = join(UIT, `${app}.html`);
|
||||||
|
writeFileSync(doel, html, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Geschreven: ${doel}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Openen kan met de voorbeeldserver uit .claude/launch.json, of door het');
|
||||||
|
console.log('bestand rechtstreeks in een browser te slepen.');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
id: whatsnext
|
||||||
|
name: WhatsNext?
|
||||||
Whitespace-only changes.
@@ -0,0 +1,77 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Atreides Assets - je eigen vermogensoverzicht (crypto en edelmetaal), lokaal.
|
||||||
|
#
|
||||||
|
# Eén container: de Node-backend serveert de SPA én bezit de database. Het recept
|
||||||
|
# staat NIET in deze repo maar in `HomeGit/AtreidesAssets` (de Dockerfile in de root
|
||||||
|
# daar); deze store draagt alleen de app-definitie. Zie het plan Distributie,
|
||||||
|
# PLAN.md §5a: de scheiding loopt tussen repo's en niet tussen mappen.
|
||||||
|
#
|
||||||
|
# Geen `ports:` hier, en dat is het verschil met de andere twee apps in deze
|
||||||
|
# store. Die publiceren een eigen poort omdat een cliënt zónder browsersessie
|
||||||
|
# erbij moet (een wallet, een sync-cliënt). Dit is een web-UI en niets anders,
|
||||||
|
# dus hij hangt volledig achter app_proxy en umbrelOS bepaalt de poort.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
services:
|
||||||
|
# umbrelOS genereert deze service zelf; wij vullen alleen in waar hij heen moet
|
||||||
|
# wijzen. De hostnaam heeft de vorm <app-id>_<compose-service>_1.
|
||||||
|
#
|
||||||
|
# PROXY_AUTH_ADD blijft weg, dus de inlog van umbrelOS staat ervoor. Dat is hier
|
||||||
|
# de bedoeling: dit is een administratie en niet een dienst voor een cliënt.
|
||||||
|
app_proxy:
|
||||||
|
environment:
|
||||||
|
APP_HOST: whatsnext-atreides-assets_server_1
|
||||||
|
APP_PORT: 8080
|
||||||
|
|
||||||
|
server:
|
||||||
|
# De digest hoort erbij en niet alleen de tag: een tag kan opnieuw geduwd
|
||||||
|
# worden en dan draait er iets anders dan hier staat. Staan ze allebei, dan
|
||||||
|
# bepaalt de digest wat er gehaald wordt en is de tag alleen leesbaarheid.
|
||||||
|
# Geduwd op 14-09-2026.
|
||||||
|
#
|
||||||
|
# DE TAG IS DE VERSIE VAN DE IMAGE EN NIET DIE VAN DE APP. `version` in
|
||||||
|
# umbrel-app.yml loopt erop vooruit, want dat moet bij élke wijziging omhoog,
|
||||||
|
# ook als alleen dit bestand verandert; de tag alleen als er een nieuwe image
|
||||||
|
# is. Vandaar 0.1.1 in het manifest naast 0.1.0 hier.
|
||||||
|
#
|
||||||
|
# Er hoort geen build.sh bij deze app, want het recept woont in de
|
||||||
|
# AssetApp-repo; de toets in tests/test_appstore_vorm.py slaat de
|
||||||
|
# tag-vergelijking daarom over.
|
||||||
|
#
|
||||||
|
# Bij een verhoging: eerst de digest hier wéghalen, dan bouwen en duwen, dan
|
||||||
|
# de nieuwe erin. Een OUDE digest onder een NIEUWE tag levert stilzwijgend de
|
||||||
|
# oude image.
|
||||||
|
image: sc.kamenier-hamer.nl/sysop/atreides-assets:0.2.0@sha256:646fe7a169d9da0bad0df43e249237c88c7641d6c88f52cdd984b88c562d2859
|
||||||
|
restart: on-failure
|
||||||
|
# Geen `user:` nodig: de image zet zelf `USER node`. Dat is anders dan bij
|
||||||
|
# Evolu Relay, waar de image er geen heeft en de compose het per container
|
||||||
|
# regelt.
|
||||||
|
environment:
|
||||||
|
# ZONDER DIT IS DE APP ONBEREIKBAAR. De server bindt op 127.0.0.1 zolang TLS
|
||||||
|
# én inloggegevens ontbreken, en in een container is dat de loopback van de
|
||||||
|
# container: app_proxy komt er dan niet bij. De netwerkgrens is hier de
|
||||||
|
# afwezigheid van een `ports:`-regel plus de proxy, en niet de app zelf.
|
||||||
|
ASSETAPP_HOST: "0.0.0.0"
|
||||||
|
# HIER STAAT MET OPZET GEEN `ASSETAPP_PASSWORD`, en dat is een correctie op
|
||||||
|
# 0.1.0 (14-09-2026). Daar stond `${APP_PASSWORD}`, en dat gaf twee
|
||||||
|
# problemen tegelijk:
|
||||||
|
#
|
||||||
|
# 1. umbrelOS vult die variabele alleen aan een app die er in zijn
|
||||||
|
# manifest om vraagt, dus hij kwam leeg binnen en de app draaide
|
||||||
|
# zónder eigen slot;
|
||||||
|
# 2. en gevuld was ook niet goed geweest. De app leest de omgeving vóór
|
||||||
|
# het geheimenbestand, dus verbergt hij op Systeem -> Portefeuilles de
|
||||||
|
# knop om het wachtwoord te wijzigen. Het is dan alleen te veranderen
|
||||||
|
# door dit bestand aan te passen, en dat is voor een Umbrel-app de
|
||||||
|
# verkeerde plek.
|
||||||
|
#
|
||||||
|
# Het wachtwoord hoort dus in de portefeuille zelf, te zetten op
|
||||||
|
# Systeem -> Portefeuilles. Daar is het te wijzigen en het gaat mee in de
|
||||||
|
# back-up van umbrelOS. Tot je het zet, is de inlog van umbrelOS het enige
|
||||||
|
# slot, en dat beschermt niet tegen een andere app op hetzelfde
|
||||||
|
# Docker-netwerk.
|
||||||
|
volumes:
|
||||||
|
# De INSTALLATIEMAP van de app: active.json, tls/ en één map per
|
||||||
|
# portefeuille. Alles wat de gebruiker bezit staat hieronder, dus dit is de
|
||||||
|
# map die in de back-up van umbrelOS hoort.
|
||||||
|
- ${APP_DATA_DIR}/data/app:/data
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,97 @@
|
|||||||
|
# De veldvolgorde hieronder is niet vrij: de packaging-documentatie van de
|
||||||
|
# officiele appstore schrijft hem voor. Zie de tabel in
|
||||||
|
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md, en de toets in
|
||||||
|
# tests/test_appstore_vorm.py die hem vasthoudt.
|
||||||
|
#
|
||||||
|
# Wat de spec niet noemt, staat onderaan dit bestand en niet ertussen. Dan is de
|
||||||
|
# voorgeschreven kop letterlijk goed en hoeft er bij inlevering alleen iets weg.
|
||||||
|
manifestVersion: 1
|
||||||
|
id: whatsnext-atreides-assets
|
||||||
|
category: finance
|
||||||
|
name: Atreides Assets
|
||||||
|
version: "0.2.0"
|
||||||
|
tagline: What you own, on your own machine
|
||||||
|
description: >-
|
||||||
|
A portfolio that nobody else gets to read. Every exchange dashboard and every tracking app
|
||||||
|
answers to someone: it knows what you hold, what you bought it for, and when you moved it.
|
||||||
|
This one runs on your Umbrel, keeps its database in your app folder, and talks to nothing
|
||||||
|
you did not point it at.
|
||||||
|
|
||||||
|
|
||||||
|
It tracks crypto and precious metals side by side, in one total. Balances come from two
|
||||||
|
places and it keeps them apart on purpose: what a wallet actually holds on chain, and what
|
||||||
|
your own bookkeeping says should be there. When those two drift, the app says so instead of
|
||||||
|
picking a winner, and shows you the entries behind the difference.
|
||||||
|
|
||||||
|
|
||||||
|
Prices come from providers you choose and configure yourself, with your own API keys. There
|
||||||
|
is no built-in account, no phone-home, and no analytics. The keys stay on the server side
|
||||||
|
and never reach your browser.
|
||||||
|
|
||||||
|
|
||||||
|
What it does beyond a list of holdings: a wealth chart over any year you have data for, the
|
||||||
|
figures you need for the Dutch box 3 declaration, signal charts for those who want them,
|
||||||
|
and a dashboard you arrange yourself. A Lightning wallet can be connected over NWC or LND
|
||||||
|
to read its balance.
|
||||||
|
|
||||||
|
|
||||||
|
It also speaks MCP, so an AI assistant you already use can read the administration and
|
||||||
|
propose entries. Proposals land in a queue and nothing is written until you approve it, and
|
||||||
|
what the assistant was shown is logged per call.
|
||||||
|
|
||||||
|
|
||||||
|
Your data is a directory: a database file and a secrets file per portfolio. Back it up,
|
||||||
|
move it to another machine, or keep several administrations side by side and switch between
|
||||||
|
them. There is no export you have to ask for.
|
||||||
|
releaseNotes: >-
|
||||||
|
The first update with real features since the app came to umbrelOS. It upgrades the database
|
||||||
|
on first start, so make sure your backup is recent before you update.
|
||||||
|
|
||||||
|
|
||||||
|
Every portfolio now has its own base currency, chosen when you create it. Amounts and prices
|
||||||
|
are shown in that currency throughout, and sources that quote in another currency are
|
||||||
|
converted on the way in.
|
||||||
|
|
||||||
|
|
||||||
|
Every price now shows where it came from: hover over an amount to see the source and the
|
||||||
|
date of the price behind it. A booking records the amount you actually paid next to the price
|
||||||
|
per unit, and a transfer can be marked as a gift. Gaps in the price history are shown per
|
||||||
|
asset and can be filled by hand or ignored. You choose a price source per asset, and the app
|
||||||
|
keeps each source within its monthly limit. Eulerpool is added as a source.
|
||||||
|
|
||||||
|
|
||||||
|
The wealth page shows return, realised and unrealised result, and interest and rewards as
|
||||||
|
income, with an overview per year. Events has a new Accounts tab with the current balance of
|
||||||
|
every account. Assets can carry notes. System, Health checks the data for problems you can
|
||||||
|
resolve or accept.
|
||||||
|
|
||||||
|
|
||||||
|
A second look called Glass sits next to the existing one, with new accent colours and a
|
||||||
|
reworked settings page. The AI connection (MCP) can now also read price data and propose a
|
||||||
|
price source.
|
||||||
|
developer: "WhatsNext?"
|
||||||
|
website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
dependencies: []
|
||||||
|
repo: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
support: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/issues
|
||||||
|
# De poort waarop umbrelOS de web-UI aanbiedt. 3850 en 3851 zijn bezet door de
|
||||||
|
# twee andere apps in deze store, 3852 is de eigen poort van de relay.
|
||||||
|
port: 3853
|
||||||
|
# Leeg laten bij een nieuw pakket; de plaatjes doet het store-team zelf.
|
||||||
|
gallery: []
|
||||||
|
path: ""
|
||||||
|
submitter: "WhatsNext?"
|
||||||
|
submission: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
|
||||||
|
# ── Hieronder staat wat de voorgeschreven volgorde niet noemt ────────────────
|
||||||
|
#
|
||||||
|
# GEEN defaultPassword, en dat is een correctie op 0.1.0. Deze app heeft wél een
|
||||||
|
# eigen inlog, maar die hoort bij de portefeuille en niet bij de installatie: je
|
||||||
|
# zet hem op Systeem -> Portefeuilles, hij is daar te wijzigen en hij gaat mee in
|
||||||
|
# de back-up. Een wachtwoord uit de omgeving zou dat scherm stil onwerkzaam maken,
|
||||||
|
# want de app leest de omgeving vóór het geheimenbestand.
|
||||||
|
|
||||||
|
# Het icoon hoort bij een eigen store en moet bij inlevering in de officiele
|
||||||
|
# store juist weg: die host iconen apart. Daarom staat het als laatste, want dan
|
||||||
|
# is dit de enige regel die eruit moet en blijft de rest letterlijk op orde.
|
||||||
|
icon: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/raw/branch/main/whatsnext-atreides-assets/icon.png
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,136 @@
|
|||||||
|
services:
|
||||||
|
# umbrelOS genereert deze service zelf; wij vullen alleen in waar hij heen moet
|
||||||
|
# wijzen. De hostnaam heeft de vorm <app-id>_<compose-service>_1.
|
||||||
|
app_proxy:
|
||||||
|
environment:
|
||||||
|
APP_HOST: whatsnext-electrum-gate_server_1
|
||||||
|
APP_PORT: 80
|
||||||
|
|
||||||
|
# Beide containers draaien dezelfde image, gebouwd door tools/electrum-gate/
|
||||||
|
# build.sh. Wat erin zit staat in de Dockerfile daar: nginx met de pagina en het
|
||||||
|
# stream-blok, en python3 met de agent. Tot 0.0.29 stonden die bestanden hier
|
||||||
|
# als *.template en draaiden ze op python:3-alpine en nginx:alpine; waarom dat
|
||||||
|
# veranderd is staat in het plan Eigenimage, PLAN.md §3.
|
||||||
|
#
|
||||||
|
# De tag hoort gelijk te zijn aan VERSION in build.sh, en er hoort een digest
|
||||||
|
# achter zodra de image geduwd is. Let op de valstrik uit Kennis.md: een
|
||||||
|
# oude digest onder een nieuwe tag levert stilzwijgend de oude image, dus laat
|
||||||
|
# de digest er even van af zolang de nieuwe niet geduwd is.
|
||||||
|
|
||||||
|
# De agent. Hij schrijft status.json, leest de certificaten, bevraagt de
|
||||||
|
# Electrum-server en neemt de certificaatkeuze aan.
|
||||||
|
#
|
||||||
|
# Waarom een tweede container en niet een lus in de server hieronder: dit werk
|
||||||
|
# is een programma. Een certificaatdatum uitlezen, een JSON-RPC-verzoek doen,
|
||||||
|
# een geschiedenis bijhouden en een keuze valideren zijn geen dingen die je met
|
||||||
|
# openssl en nc aan elkaar knoopt zonder dat het stil verkeerde antwoorden gaat
|
||||||
|
# geven.
|
||||||
|
agent:
|
||||||
|
image: sc.kamenier-hamer.nl/sysop/electrum-gate:0.1.0@sha256:97fee09ba8004c2f0cf55e03cb612474f0a4b24724f9186a4e6769cebb7cfd8f
|
||||||
|
restart: on-failure
|
||||||
|
command:
|
||||||
|
- python3
|
||||||
|
- /app/agent.py
|
||||||
|
environment:
|
||||||
|
# Het adres van de Electrum-server die de gebruiker in umbrelOS gekozen
|
||||||
|
# heeft. umbrelOS vult dit in op grond van de afhankelijkheid hieronder,
|
||||||
|
# dus dit werkt met Electrs, Fulcrum en ElectrumX.
|
||||||
|
GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}
|
||||||
|
GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}
|
||||||
|
GATE_TLS_PORT: "50022"
|
||||||
|
# De versie uit het manifest, voor de kop van de pagina. Die stond tot
|
||||||
|
# 0.0.29 rechtstreeks in de pagina, ingevuld door umbreld; nu de pagina in
|
||||||
|
# de image zit, geeft de agent hem door via status.json.
|
||||||
|
GATE_APP_VERSION: ${APP_VERSION}
|
||||||
|
# De zelfcontrole: de agent verbindt met de TLS-poort van de server
|
||||||
|
# hieronder en maakt de handdruk af. Niet localhost: dat is een andere
|
||||||
|
# container en dus een andere netwerk-namespace.
|
||||||
|
#
|
||||||
|
# De volledige containernaam en niet de servicenaam 'server'. Dat stond er
|
||||||
|
# eerst, en op het apparaat gaf het `[Errno 111] Connection refused`
|
||||||
|
# (27-08-2026). Die fout is veelzeggend: geweigerd betekent dat de naam wél
|
||||||
|
# oploste, alleen niet naar ons. 'server' is een naam die meer apps op deze
|
||||||
|
# machine gebruiken, en op een gedeeld netwerk is het dus een gok wie je
|
||||||
|
# krijgt. De vorm <app-id>_<service>_1 is uniek per app; het is niet toevallig
|
||||||
|
# dezelfde vorm die umbrelOS hierboven van APP_HOST verlangt.
|
||||||
|
GATE_TLS_HOST: whatsnext-electrum-gate_server_1
|
||||||
|
# Niet elke ronde meten, want elke meting is voor nginx een gewone sessie
|
||||||
|
# en levert dus een regel in het activiteitenlog op. Vijf minuten is vaak
|
||||||
|
# genoeg voor een waarde die zelden verandert; na een certificaatwissel
|
||||||
|
# wordt er sowieso gemeten.
|
||||||
|
GATE_SELF_CHECK_INTERVAL: "300"
|
||||||
|
GATE_INTERVAL: "60"
|
||||||
|
GATE_API_PORT: "8000"
|
||||||
|
GATE_STATE_DIR: /var/lib/gate
|
||||||
|
# Bron-naam=map. Een bron die niet gemount is, bestaat niet en wordt
|
||||||
|
# overgeslagen; dat is de normale toestand voor een reverse proxy die de
|
||||||
|
# gebruiker niet draait.
|
||||||
|
GATE_CERT_SOURCES: "Zoraxy=/certs/zoraxy,Own folder=/certs/own"
|
||||||
|
# De enige beschrijfbare bron, en dus de map waarin een upload van de
|
||||||
|
# pagina landt. Expliciet en niet afgeleid uit de lijst hierboven: welke
|
||||||
|
# bron beschrijfbaar is, hoort te staan naast de mount die dat toestaat.
|
||||||
|
# Leeg zetten schakelt uploaden uit.
|
||||||
|
GATE_UPLOAD_DIR: /certs/own
|
||||||
|
volumes:
|
||||||
|
# De gedeelde toestand: status.json, cert.conf, de herlaadvlag en de
|
||||||
|
# gekozen certificaat-id. Beide containers zitten hierin.
|
||||||
|
#
|
||||||
|
# Onder data/, want dat is wat andere apps doen: electrs mount
|
||||||
|
# ${APP_DATA_DIR}/data/electrs, mempool ${APP_DATA_DIR}/data. Verplaatst op
|
||||||
|
# 20-08-2026 met het oog op publicatie in de officiele store.
|
||||||
|
- ${APP_DATA_DIR}/data/runtime:/var/lib/gate
|
||||||
|
# De certificaatbronnen. Zoraxy alleen lezen: dat zijn de certificaten van
|
||||||
|
# een andere app en die raakt deze app niet aan.
|
||||||
|
- ${UMBREL_ROOT}/app-data/zoraxy/data/config/conf/certs:/certs/zoraxy:ro
|
||||||
|
# De eigen map is de enige die beschrijfbaar is, want hier landt een upload
|
||||||
|
# van de pagina. Alleen de agent; de nginx-container houdt hem alleen-lezen,
|
||||||
|
# want die hoeft er nooit iets neer te zetten.
|
||||||
|
- ${APP_DATA_DIR}/data/certs:/certs/own
|
||||||
|
#
|
||||||
|
# Nginx Proxy Manager hoort hier als derde bron bij en staat er bewust nog
|
||||||
|
# niet in. Het pad hieronder is een gok en niet geverifieerd, en een
|
||||||
|
# bind-mount naar een pad dat niet bestaat laat Docker het aanmaken. Dat zou
|
||||||
|
# een lege maphierarchie neerzetten in de app-data van een app die
|
||||||
|
# misschien niet eens geinstalleerd is, en die rommel blijft daar staan.
|
||||||
|
# Eerst op de Umbrel controleren waar NPM zijn certificaten neerzet, dan
|
||||||
|
# deze regel aanzetten en de naam toevoegen aan GATE_CERT_SOURCES.
|
||||||
|
#
|
||||||
|
# - ${UMBREL_ROOT}/app-data/nginx-proxy-manager/data/letsencrypt/live:/certs/npm:ro
|
||||||
|
|
||||||
|
# nginx: de pagina op poort 80 achter de app_proxy, en de TLS-poort 50022 zodra
|
||||||
|
# de agent een certificaat gekozen heeft. Het startscript is entrypoint.sh in de
|
||||||
|
# image; tot 0.0.29 stond dat hier als command-blok van ruim honderd regels, met
|
||||||
|
# elk dollarteken verdubbeld. Wat het doet staat daar uitgelegd.
|
||||||
|
server:
|
||||||
|
image: sc.kamenier-hamer.nl/sysop/electrum-gate:0.1.0@sha256:97fee09ba8004c2f0cf55e03cb612474f0a4b24724f9186a4e6769cebb7cfd8f
|
||||||
|
restart: on-failure
|
||||||
|
# De agent moet er zijn voordat nginx start, want de proxy_pass naar de agent
|
||||||
|
# wordt bij het starten opgelost en een onbekende naam laat nginx afbreken.
|
||||||
|
# Op de cert.conf van de agent wordt niet gewacht: de pagina komt hoe dan ook
|
||||||
|
# omhoog, zie entrypoint.sh.
|
||||||
|
depends_on:
|
||||||
|
- agent
|
||||||
|
ports:
|
||||||
|
# De TLS-poort voor Electrum-wallets. Dit is de enige poort die deze app
|
||||||
|
# zelf publiceert; de web-UI loopt via app_proxy.
|
||||||
|
#
|
||||||
|
# 50022 en niet de conventionele 50002, omdat Fulcrum die op de host
|
||||||
|
# bezet: met Fulcrum als backend zou de container niet starten, en dat is
|
||||||
|
# precies het omschakelen dat deze app moet ondersteunen. De poort naar
|
||||||
|
# buiten is toch al een andere, want die staat in de router doorgestuurd.
|
||||||
|
# Beslist 19-08-2026, open punt 1 van het plan Configuratie.
|
||||||
|
- "50022:50022"
|
||||||
|
environment:
|
||||||
|
# Dezelfde twee als bij de agent. entrypoint.sh vult ze in het stream-blok
|
||||||
|
# in, want nginx kan in een proxy_pass geen omgevingsvariabele lezen. Tot
|
||||||
|
# 0.0.29 deed umbreld dat via stream.conf.template.
|
||||||
|
GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}
|
||||||
|
GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}
|
||||||
|
volumes:
|
||||||
|
# Dezelfde gedeelde toestand als de agent. nginx leest hier cert.conf en
|
||||||
|
# status.json, en schrijft de sessielog.
|
||||||
|
- ${APP_DATA_DIR}/data/runtime:/var/lib/gate
|
||||||
|
# De certificaten zelf moeten ook hier gemount zijn: de agent bepaalt
|
||||||
|
# welk pad in cert.conf komt, maar nginx moet het bestand kunnen openen.
|
||||||
|
- ${UMBREL_ROOT}/app-data/zoraxy/data/config/conf/certs:/certs/zoraxy:ro
|
||||||
|
- ${APP_DATA_DIR}/data/certs:/certs/own:ro
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,236 @@
|
|||||||
|
# De veldvolgorde hieronder is niet vrij: de packaging-documentatie van de
|
||||||
|
# officiele appstore schrijft hem voor. Zie de tabel in
|
||||||
|
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md, en de toets in
|
||||||
|
# tests/test_server_start_zonder_certificaat.py die hem vasthoudt.
|
||||||
|
#
|
||||||
|
# Wat de spec niet noemt, staat onderaan dit bestand en niet ertussen. Dan is de
|
||||||
|
# voorgeschreven kop letterlijk goed en hoeft er bij inlevering alleen iets weg.
|
||||||
|
manifestVersion: 1
|
||||||
|
id: whatsnext-electrum-gate
|
||||||
|
category: bitcoin
|
||||||
|
name: Electrum Gate
|
||||||
|
version: "0.1.0"
|
||||||
|
tagline: Your own node from anywhere, without waiting for Tor
|
||||||
|
description: >-
|
||||||
|
The privacy win is already yours: you run the Electrum server. A public one gets asked for
|
||||||
|
the history of every address in your wallet, and that tells it which addresses and which
|
||||||
|
balance belong to one person. Your own server never reports back.
|
||||||
|
|
||||||
|
|
||||||
|
What is left is a trade. Tor is the more private way in, and every wallet below speaks it.
|
||||||
|
But it adds hundreds of milliseconds to every request, it drops when a phone sleeps or
|
||||||
|
changes network, and plenty of networks block it outright. Electrum Gate is the other side
|
||||||
|
of that trade: a TLS front door on your node. Fast, no fingerprint to type over, and it
|
||||||
|
works on any network you happen to be on.
|
||||||
|
|
||||||
|
|
||||||
|
It reuses the Let's Encrypt certificate your reverse proxy already manages, so there is
|
||||||
|
nothing to request and nothing to renew. A renewal is picked up on its own, without
|
||||||
|
dropping connections that are already open. You can also upload a certificate you manage
|
||||||
|
yourself, if you do not run a reverse proxy on this machine.
|
||||||
|
|
||||||
|
|
||||||
|
Nothing in the middle. The connection runs from your wallet straight to your own node,
|
||||||
|
encrypted with a certificate you already own, for a domain you already control. There is no
|
||||||
|
account to create, no tunnel service that terminates your traffic along the way, and no
|
||||||
|
client to install on every device you use: the wallets below already speak TLS, they only
|
||||||
|
need an address. What it does ask of you is one forwarded port on your router.
|
||||||
|
|
||||||
|
|
||||||
|
Useful for wallets connecting from outside your home: Trezor Suite, Electrum, Sparrow,
|
||||||
|
BlueWallet, Nunchuk, Blockstream and BitBoxApp. Apps on the Umbrel itself do not need
|
||||||
|
it, they already reach the Electrum server directly.
|
||||||
|
|
||||||
|
|
||||||
|
The dashboard hands you the exact line each of those wallets asks for, and shows how long
|
||||||
|
your certificate is still good for and whether your Electrum server is answering. Electrs,
|
||||||
|
Fulcrum and ElectrumX all work, switchable in the umbrelOS settings.
|
||||||
|
|
||||||
|
|
||||||
|
What it does not do: hide that the traffic exists. Your domain is public in the certificate
|
||||||
|
transparency logs. If that matters more to you than speed, use Tor instead.
|
||||||
|
|
||||||
|
# Eén verhaal over deze versie, dan één regel per eerdere versie. Niet meer dan
|
||||||
|
# dat, en dat staat hier omdat het drie versies achter elkaar fout ging: bij elke
|
||||||
|
# release kwam er een nieuwe kop bovenop terwijl de oude tekst eronder bleef
|
||||||
|
# staan. In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer
|
||||||
|
# dezelfde 0.0.4-regel. De toets let er nu op.
|
||||||
|
releaseNotes: >-
|
||||||
|
The app now ships as its own image instead of mounting its program files into two stock images. Nothing
|
||||||
|
changes in what it does; what changes is where the code lives. The app data folder now holds only your
|
||||||
|
data: the certificate you chose, the one you uploaded, and the status the page reads. The startup logic
|
||||||
|
that used to be a hundred lines of shell inside the compose file is a plain script inside the image, and
|
||||||
|
the app icon updates along with everything else, which it could not before.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.0.29:
|
||||||
|
|
||||||
|
|
||||||
|
The glow behind the page is gone. It arrived in 0.0.25 and it did what it was meant to do, but a status
|
||||||
|
page is something you glance at, and a coloured wash over the whole window is a lot of decoration for a
|
||||||
|
glance. The panels keep their edge, their shadow and their top light, so they still read as panels; only
|
||||||
|
the light behind them is off. The switch is still in the page for anyone who wants it back.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.0.28:
|
||||||
|
|
||||||
|
|
||||||
|
The block height tile is gone. This app is a front door for your Electrum server; what the chain is
|
||||||
|
doing is not its news to report, and every wallet and block explorer you own already tells you. Four
|
||||||
|
tiles are left, all of them about this gateway: which server your traffic goes to, whether it answers
|
||||||
|
and how fast, how long your certificate is still good for, and whether the front door itself is up.
|
||||||
|
How old the readings are now sits under the response time, where it used to sit under the height.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.0.27:
|
||||||
|
|
||||||
|
|
||||||
|
Fixes the Status card reading "Not answering" for five minutes after every restart or update, with
|
||||||
|
a connection error underneath, while the gateway itself was working fine the whole time. The check
|
||||||
|
runs from inside the app and it ran before the part it checks had started, so the very first
|
||||||
|
measurement of every restart was bound to fail, and nothing looked again for five minutes. It now
|
||||||
|
says Checking on that first round and takes a real measurement a minute later, and a failed
|
||||||
|
measurement is repeated within a minute instead of after five.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.0.26:
|
||||||
|
|
||||||
|
|
||||||
|
Two adjustments to the refresh. The Status card reads in the accent colour again when it says
|
||||||
|
Running, and on a phone the page drops back to the flat look: the glow is wider than a phone screen,
|
||||||
|
so it stopped being a gradient and became a haze over everything.
|
||||||
|
|
||||||
|
|
||||||
|
Earlier releases:
|
||||||
|
|
||||||
|
|
||||||
|
0.0.25 was a visual refresh shared with the other app in this store: translucent cards with a light edge
|
||||||
|
and a shadow, one corner radius for every framed element, and a glow behind the page. That glow is the
|
||||||
|
one this release switches off; the rest of it is still there.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.24 added an "About this app" item to the menu, and changed the Status card from "Answering" with
|
||||||
|
"answered in 7 ms, from inside the app" underneath to "Running" with the measurement below it. The
|
||||||
|
caveat moved into that dialog, where there is room to state it: the check runs inside the app, so a
|
||||||
|
router that no longer forwards the port still reads as Running here.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.23 fixed a page that could go blank or show stale values once another app from the same store was
|
||||||
|
installed. The page reached its helper by a short name that a second app happened to use as well, so
|
||||||
|
roughly half of its requests ended up at the wrong one.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.22 gave panels in light mode an outline as well as a shadow. A soft shadow on white gives depth but
|
||||||
|
no edge, so where a panel ended was a matter of looking closely. Dark mode was unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.21 put every tile value at the same size, so a block height no longer looked more important than a
|
||||||
|
server name beside it, and gave the dialog a cross to close instead of the word "Close".
|
||||||
|
|
||||||
|
|
||||||
|
0.0.20 made the five tiles a little taller, so their titles and values are no longer crowded together.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.19 moved the text under each tile down to the bottom, level with the response graph beside it, and
|
||||||
|
put the arrow on "Setting up your wallet" to the left of the title where the browser would have put it.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.18 fixed the Status tile reporting "Not answering" on a gateway that was working fine: the check was
|
||||||
|
connecting to the wrong container. It also made the menu three plain dots instead of a button, renamed
|
||||||
|
its first item to "Settings", opened the dialog in the middle of the screen instead of the top left,
|
||||||
|
made the tiles taller with their values along the bottom, swapped response time and block height, and
|
||||||
|
halved the height of the activity log.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.17 rebuilt the dashboard around five small tiles, moved choosing and uploading certificates into a
|
||||||
|
dialog behind the menu at the top right, put the activity log across the full width, and collapsed
|
||||||
|
"Setting up your wallet" into a much shorter panel with two copy buttons instead of one per wallet.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.16 taught the app to check its own front door: it connects to its own TLS port, completes the
|
||||||
|
handshake, and compares the certificate it gets back with the one you picked. That catches a
|
||||||
|
certificate change that never actually took effect, which looks fine from the outside and fails at your
|
||||||
|
wallet. It does not tell you whether your gateway can be reached from outside; the check runs inside the
|
||||||
|
app.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.15 moved the app store this app comes from to a new address, and pointed every link in this listing
|
||||||
|
there. If you added the store at its old address, remove it in umbrelOS and add the new one.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.14 stopped calling a scan from the internet a refusal. Those now read "probe", in grey, and red
|
||||||
|
is kept for a session that did carry traffic and then broke. The panels also got more room between
|
||||||
|
them.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.13 made the certificate panel and the activity log grow and shrink together, so opening the
|
||||||
|
upload section no longer leaves a gap under the button.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.12 tightened the spacing in the certificate panel, and dropped a line claiming certificates
|
||||||
|
are read from Nginx Proxy Manager.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.11 lined up the copy buttons along the right edge on a phone, and moved the upload section
|
||||||
|
above the certificate picker.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.10 made the dashboard work on a phone, and dropped the tile counting the days left on your
|
||||||
|
certificate.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.9 moved everything the app writes into the "data" folder inside the app folder, which is
|
||||||
|
where the App Store expects an app to keep its state.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.8 gave the dashboard the same line as this listing, instead of describing TLS as a means.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.7 added uploading your own certificate, with the key checked against the certificate before
|
||||||
|
anything is stored.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.6 made a connected wallet visible while it is connected, turned the certificate picker into
|
||||||
|
a dropdown, and rewrote the session line in the activity log.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.5 shortened the message you get when more than one certificate was found.
|
||||||
|
|
||||||
|
|
||||||
|
0.0.4 fixed an install that showed no dashboard at all.
|
||||||
|
|
||||||
|
developer: "WhatsNext?"
|
||||||
|
website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
dependencies:
|
||||||
|
- electrs
|
||||||
|
repo: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
support: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/issues
|
||||||
|
# De poort waarop umbrelOS de web-UI van deze app aanbiedt, niet de TLS-poort.
|
||||||
|
# Die staat in docker-compose.yml en is 50022.
|
||||||
|
port: 3850
|
||||||
|
# Leeg laten bij een nieuw pakket; de plaatjes doet het store-team zelf.
|
||||||
|
gallery: []
|
||||||
|
path: ""
|
||||||
|
submitter: "WhatsNext?"
|
||||||
|
submission: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
|
||||||
|
# ── Hieronder staat wat de voorgeschreven volgorde niet noemt ────────────────
|
||||||
|
#
|
||||||
|
# defaultUsername en defaultPassword stonden hier met een lege waarde en zijn
|
||||||
|
# eruit. Deze app heeft geen eigen inlog; de app_proxy van umbrelOS zet er zijn
|
||||||
|
# eigen voor. Een leeg veld zegt niets en suggereert dat er iets te vullen valt.
|
||||||
|
|
||||||
|
# Wat niet in de back-up hoeft. Paden zijn relatief aan de app-datamap en
|
||||||
|
# ondersteunen een jokerteken; nagekeken in app.ts van umbreld op 20-08-2026.
|
||||||
|
#
|
||||||
|
# Alleen de twee die groeien of elke minuut veranderen. Wat er bewust NIET bij
|
||||||
|
# staat is data/runtime/config: daar zit de certificaatkeuze van de gebruiker, en
|
||||||
|
# die is het enige in deze map dat niet opnieuw te bedenken is.
|
||||||
|
backupIgnore:
|
||||||
|
- data/runtime/stream.log
|
||||||
|
- data/runtime/status.json
|
||||||
|
|
||||||
|
# Het icoon hoort bij een eigen store en moet bij inlevering in de officiele
|
||||||
|
# store juist weg: die host iconen apart. Daarom staat het als laatste, want dan
|
||||||
|
# is dit de enige regel die eruit moet en blijft de rest letterlijk op orde.
|
||||||
|
icon: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/raw/branch/main/whatsnext-electrum-gate/icon.png
|
||||||
Whitespace-only changes.
@@ -0,0 +1,137 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Evolu Relay - je eigen sync-server voor apps die op Evolu gebouwd zijn.
|
||||||
|
#
|
||||||
|
# Drie containers: de relay, een agent voor de statuspagina, en nginx die die
|
||||||
|
# pagina serveert. Geen Postgres en geen quota-manager meer; die hoorden bij de
|
||||||
|
# relay van Trezor, en die is als zelf-gehoste relay in de kern onbruikbaar omdat
|
||||||
|
# de cliënt bij een eigen relay-URL nooit een eigenaar registreert. Zie het plan
|
||||||
|
# Umbrelapp, OPEN.md punt 6, en de proef in PLAN.md §6a.
|
||||||
|
#
|
||||||
|
# De poortindeling is omgedraaid ten opzichte van 25-08-2026, en dat is de kern
|
||||||
|
# van dit bestand:
|
||||||
|
#
|
||||||
|
# - de PAGINA hangt achter app_proxy, mét de inlog van umbrelOS. Vroeger stond de
|
||||||
|
# relay daar en moest de inlog dus uit, waardoor een statuspagina net zo
|
||||||
|
# onbeschermd zou zijn als de relay zelf;
|
||||||
|
# - de RELAY publiceert zijn eigen poort, waar Zoraxy met TLS naartoe wijst.
|
||||||
|
# een sync-cliënt is geen browser met een sessiecookie en zou achter de inlog
|
||||||
|
# een inlogpagina krijgen in plaats van de relay.
|
||||||
|
#
|
||||||
|
# Dat is hetzelfde patroon als Electrum Gate in deze store: de web-UI via de
|
||||||
|
# proxy, het protocol op een eigen poort. Zie PLAN.md §4h.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
services:
|
||||||
|
# umbrelOS genereert deze service zelf; wij vullen alleen in waar hij heen moet
|
||||||
|
# wijzen. De hostnaam heeft de vorm <app-id>_<compose-service>_1.
|
||||||
|
#
|
||||||
|
# PROXY_AUTH_ADD staat hier bewust NIET op "false". Dat stond er tot 0.0.2 wel,
|
||||||
|
# toen de relay hierachter hing. Nu wijst dit naar de statuspagina, en die hoort
|
||||||
|
# juist achter de inlog. Zet het niet terug zonder §4h te lezen.
|
||||||
|
app_proxy:
|
||||||
|
environment:
|
||||||
|
APP_HOST: whatsnext-evolu-relay_server_1
|
||||||
|
APP_PORT: 80
|
||||||
|
|
||||||
|
# Alle drie de containers draaien dezelfde image, gebouwd uit tools/evolu-relay/
|
||||||
|
# door build.sh en geduwd naar het Gitea-register op dezelfde server als deze
|
||||||
|
# store. Dat moet: umbreld haalt élke image op via de Docker Engine API en niet
|
||||||
|
# via compose, dus een lokaal gebouwde tag is voor hem onbereikbaar en de
|
||||||
|
# installatie faalt met "pull access denied". Op 25-08-2026 op het apparaat
|
||||||
|
# vastgesteld.
|
||||||
|
#
|
||||||
|
# Tot 0.5.6 zat alleen de relay in die image en draaiden de agent en de pagina
|
||||||
|
# als *.template op python:3-alpine en nginx:alpine. Sinds 0.6.0 zit alles erin;
|
||||||
|
# zie het plan Eigenimage, PLAN.md §8.
|
||||||
|
#
|
||||||
|
# De digest hoort erbij en niet alleen de tag: een tag kan opnieuw geduwd worden
|
||||||
|
# en dan draait er iets anders dan hier staat. Staan ze allebei, dan bepaalt de
|
||||||
|
# digest wat er gehaald wordt en is de tag alleen leesbaarheid. Houd de tag
|
||||||
|
# gelijk aan VERSION in build.sh (drie keer hier) en `version` in het manifest.
|
||||||
|
#
|
||||||
|
# De keerzijde bij een verhoging: een OUDE digest onder een NIEUWE tag levert
|
||||||
|
# stilzwijgend de oude image. Bij 0.5.0 stond hier daarom even een tag zonder
|
||||||
|
# digest, zodat het hard faalde tot de image bestond, in plaats van stil de
|
||||||
|
# relay zonder tijdvenster te draaien. Doe dat weer zo bij elke verhoging: eerst
|
||||||
|
# de digest weg, dan bouwen en duwen, dan de nieuwe erin.
|
||||||
|
#
|
||||||
|
# Gebouwd op de Umbrel zelf, dus één architectuur (amd64). Voor de officiele
|
||||||
|
# store hoort er een multi-arch index-digest met arm64 in; zie het masterplan
|
||||||
|
# Publicatie-Relay.
|
||||||
|
relay:
|
||||||
|
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.1@sha256:a701032aae4aba8fd7acace0c0d6c900ad6ace32eee03762b36b00c7e8c0ce6c
|
||||||
|
restart: on-failure
|
||||||
|
# Als node en niet als root, zoals de image tot 0.5.6 zelf al deed met USER.
|
||||||
|
# Nu de image ook nginx en de agent draagt, staat er geen USER meer in en
|
||||||
|
# regelt de compose het per container; de relay is de enige die het niet
|
||||||
|
# nodig heeft. De map data/relay is van deze gebruiker.
|
||||||
|
user: node
|
||||||
|
ports:
|
||||||
|
# De relay zelf. Dit is de poort waar Zoraxy met TLS naartoe wijst, en de
|
||||||
|
# enige die deze app publiceert; de pagina loopt via app_proxy.
|
||||||
|
#
|
||||||
|
# 3852 op de host en niet 4000: dat laatste is een veelgebruikte poort en
|
||||||
|
# een botsing merk je pas als de app niet start. Dezelfde les als bij
|
||||||
|
# Electrum Gate, dat om die reden 50022 gebruikt in plaats van 50002.
|
||||||
|
# Binnen de container blijft het 4000, want dat is wat de relay verwacht.
|
||||||
|
- "3852:4000"
|
||||||
|
environment:
|
||||||
|
RELAY_PORT: "4000"
|
||||||
|
# Per schrijfactie en niet per eigenaar; zo is isOwnerWithinQuota bij Evolu
|
||||||
|
# bedoeld. Eén labelwijziging is klein, dus een schrijfactie hierboven is
|
||||||
|
# eerder een fout of misbruik dan normaal gebruik. Zie PLAN.md §4g.
|
||||||
|
RELAY_MAX_WRITE_BYTES: "1048576"
|
||||||
|
volumes:
|
||||||
|
# De database van de relay én onze allowlist ernaast, in dezelfde map. Het
|
||||||
|
# programma doet chdir naar `data`, net als de relay van Evolu zelf, dus dit
|
||||||
|
# pad ligt vast.
|
||||||
|
#
|
||||||
|
# Onder data/ en niet in een naamloos Docker-volume: dit is wat umbrelOS
|
||||||
|
# bewaart en meeneemt in de back-up. In een volume zou de synchronisatie een
|
||||||
|
# herinstallatie van de app niet overleven.
|
||||||
|
- ${APP_DATA_DIR}/data/relay:/app/data
|
||||||
|
|
||||||
|
# De agent bedient de statuspagina. Hij beslist niets: hij leest wat het
|
||||||
|
# relay-proces heeft opgeschreven en legt opdrachten in een postbus die de relay
|
||||||
|
# zelf leegmaakt. Twee processen die in dezelfde allowlist schrijven is een
|
||||||
|
# wedloop die je een keer per jaar treft en dan niet kunt reproduceren.
|
||||||
|
agent:
|
||||||
|
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.1@sha256:a701032aae4aba8fd7acace0c0d6c900ad6ace32eee03762b36b00c7e8c0ce6c
|
||||||
|
restart: on-failure
|
||||||
|
command:
|
||||||
|
- python3
|
||||||
|
- /app/agent.py
|
||||||
|
environment:
|
||||||
|
RELAY_STATE_DIR: /var/lib/relay
|
||||||
|
# De versie uit het manifest, voor de kop van de pagina. Die stond tot 0.5.6
|
||||||
|
# rechtstreeks in de pagina, ingevuld door umbreld; nu de pagina in de image
|
||||||
|
# zit, geeft de agent hem door via api/status.
|
||||||
|
RELAY_APP_VERSION: ${APP_VERSION}
|
||||||
|
# De volledige containernaam en niet de servicenaam 'relay'. Bij Electrum
|
||||||
|
# Gate gaf de korte naam op het apparaat een geweigerde verbinding: de naam
|
||||||
|
# loste wél op, maar naar een container van een andere app op hetzelfde
|
||||||
|
# netwerk. De vorm <app-id>_<service>_1 is uniek per app.
|
||||||
|
RELAY_HOST: whatsnext-evolu-relay_relay_1
|
||||||
|
RELAY_PORT: "4000"
|
||||||
|
# Alleen om op de pagina het adres te tonen dat je in je cliënt invult.
|
||||||
|
# Moet gelijk zijn aan de hostpoort hierboven.
|
||||||
|
RELAY_PUBLIC_PORT: "3852"
|
||||||
|
RELAY_API_PORT: "8000"
|
||||||
|
volumes:
|
||||||
|
# Dezelfde map als de relay. De agent moet hier kunnen schrijven, want de
|
||||||
|
# opdrachten van de pagina landen hier.
|
||||||
|
- ${APP_DATA_DIR}/data/relay:/var/lib/relay
|
||||||
|
|
||||||
|
# nginx met de pagina, uit dezelfde image. Geen mounts: pagina, configuratie en
|
||||||
|
# icoon zitten in de image, en de pagina heeft geen eigen staat.
|
||||||
|
server:
|
||||||
|
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.1@sha256:a701032aae4aba8fd7acace0c0d6c900ad6ace32eee03762b36b00c7e8c0ce6c
|
||||||
|
restart: on-failure
|
||||||
|
command:
|
||||||
|
- nginx
|
||||||
|
- -g
|
||||||
|
- daemon off;
|
||||||
|
# De agent moet er zijn voordat nginx start: de proxy_pass naar de agent wordt
|
||||||
|
# bij het starten opgelost en een onbekende naam laat nginx afbreken.
|
||||||
|
depends_on:
|
||||||
|
- agent
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,195 @@
|
|||||||
|
# De veldvolgorde hieronder is niet vrij: de packaging-documentatie van de
|
||||||
|
# officiele appstore schrijft hem voor. Zie de tabel in
|
||||||
|
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md.
|
||||||
|
#
|
||||||
|
# Wat de spec niet noemt staat onderaan dit bestand en niet ertussen. Dan is de
|
||||||
|
# voorgeschreven kop letterlijk goed en hoeft er bij inlevering alleen iets weg.
|
||||||
|
#
|
||||||
|
# `dependencies` is weggelaten en niet leeg gezet: deze app hangt van geen enkele
|
||||||
|
# andere app af. Een leeg veld zou suggereren dat er iets te kiezen valt.
|
||||||
|
manifestVersion: 1
|
||||||
|
id: whatsnext-evolu-relay
|
||||||
|
category: files
|
||||||
|
name: Evolu Relay
|
||||||
|
version: "0.7.1"
|
||||||
|
tagline: Encrypted sync and backup for your local-first apps
|
||||||
|
description: >-
|
||||||
|
Local-first apps keep your data on your own device and work whether or not you have a
|
||||||
|
connection. A relay is what lets a second device catch up: it holds the encrypted changes
|
||||||
|
until your other devices come online. This app runs that relay on your Umbrel, so the copy
|
||||||
|
in the middle sits on hardware you own.
|
||||||
|
|
||||||
|
|
||||||
|
It is the relay from the Evolu project, taken straight from the package they publish and not
|
||||||
|
reimplemented here. Any app built on Evolu can use it.
|
||||||
|
|
||||||
|
|
||||||
|
Your data is encrypted on your device before it leaves. The relay holds encrypted blobs,
|
||||||
|
padded so their size says nothing, and never has the key. What it can see is an owner id,
|
||||||
|
which identifies data without identifying you, along with timestamps and the addresses that
|
||||||
|
connect. Self-hosting does not make the data more secret than it already is; what it changes
|
||||||
|
is who keeps the encrypted copy, who can see that you are syncing at all, and whether anyone
|
||||||
|
can pull the service out from under you.
|
||||||
|
|
||||||
|
|
||||||
|
What this app adds is the part a self-hosted relay needs and a public one does not: control
|
||||||
|
over who may use it. New owners are accepted one at a time. The first one that connects is
|
||||||
|
remembered, and you close the door again once your own devices are paired; anything new is
|
||||||
|
refused until you open it. Any owner can be blocked or removed later.
|
||||||
|
|
||||||
|
|
||||||
|
The tile opens a status page behind your umbrelOS login, showing whether the relay is
|
||||||
|
running, how much it holds, when it was last written to, and which owners are allowed. Point
|
||||||
|
your app at the address shown there. Away from home you will need a way in, such as Tailscale
|
||||||
|
or a reverse proxy with your own domain.
|
||||||
|
releaseNotes: >-
|
||||||
|
Tidying up the two owner panels. They are called Accepted and Waiting now, the count of owners waiting
|
||||||
|
sits in that panel's own heading instead of a widget at the top, the note under the relay address is
|
||||||
|
gone, and a row on the waiting list no longer carries a "Waiting" tag: the panel it sits in already says
|
||||||
|
that, and a blocked owner is now the only row with a tag.
|
||||||
|
|
||||||
|
|
||||||
|
Every row is the same height as well, in both panels. A row used to grow the moment you gave it a
|
||||||
|
name, because the name pushed the owner id onto a line of its own; the id now shares the second line
|
||||||
|
with the timestamps.
|
||||||
|
|
||||||
|
|
||||||
|
About this app no longer insists the sync address must start with http. Which scheme the field in your
|
||||||
|
app wants depends on the app; the host and the port are the same either way.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.6.0:
|
||||||
|
|
||||||
|
|
||||||
|
The whole app now ships as one image: the relay, the status page and the small helper behind it. Until
|
||||||
|
now only the relay did, and the other two were mounted into stock images from the app folder. Nothing
|
||||||
|
changes in what the app does; the app data folder now holds only your data, and the icon updates along
|
||||||
|
with everything else.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.5.6:
|
||||||
|
|
||||||
|
|
||||||
|
Owners are now listed most recently seen first, in both panels. Until now the rows kept the order in
|
||||||
|
which owners first showed up, which stops meaning anything after a week. The order follows along on its
|
||||||
|
own: the page refreshes its lists every few seconds, so a reconnecting owner moves to the top without a
|
||||||
|
reload.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.5.4:
|
||||||
|
|
||||||
|
|
||||||
|
The status widget has moved to the right of the top row and is now called Status, matching Electrum
|
||||||
|
Gate. The two pages are one app store and they should read the same way.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.5.3:
|
||||||
|
|
||||||
|
|
||||||
|
Two adjustments to the refresh. The Relay card reads in the accent colour again when it says
|
||||||
|
Running, and on a phone the page drops back to the flat look: the glow is wider than a phone screen,
|
||||||
|
so it stopped being a gradient and became a haze over everything.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.5.2:
|
||||||
|
|
||||||
|
|
||||||
|
A visual refresh, shared with the other apps in this store. The page now has a soft glow behind it in
|
||||||
|
the accent colour, and the cards are translucent with a light edge and a shadow, so they read as
|
||||||
|
panels sitting on the page instead of flat blocks painted on it. Card corners are a little tighter,
|
||||||
|
and every framed element now uses the same corner radius. Nothing about what the relay does has
|
||||||
|
changed.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.5.1:
|
||||||
|
|
||||||
|
|
||||||
|
Fixes a message at the top of the page that never went away. Confirming that a command was sent is worth
|
||||||
|
saying for a second or two, but it stayed until you reloaded the page, so it was still there long after it
|
||||||
|
stopped being true. It now clears itself as soon as the relay has picked the command up. A refusal is the
|
||||||
|
exception and stays put: that is the only explanation you get of what went wrong.
|
||||||
|
|
||||||
|
|
||||||
|
Everything in 0.5.0, released the same day:
|
||||||
|
|
||||||
|
|
||||||
|
Accepting a new owner is now a two minute window instead of a switch you have to remember to turn
|
||||||
|
back off. Open it, pair your device, and it closes on its own; you can close it early or restart the
|
||||||
|
two minutes if you need longer. The countdown runs on the Umbrel, so closing the page does not leave
|
||||||
|
the door open.
|
||||||
|
|
||||||
|
|
||||||
|
Owner ids can be given a name. They are long strings of random characters that say nothing about
|
||||||
|
which of your devices or wallets they are, so hover a row and label it. Names are kept on your Umbrel
|
||||||
|
and are never sent anywhere.
|
||||||
|
|
||||||
|
|
||||||
|
Blocked owners and refused attempts were two lists and are now one, because what you do with them is
|
||||||
|
the same: allow, or forget. Each row carries a badge saying which it was.
|
||||||
|
|
||||||
|
|
||||||
|
The page itself has been rebuilt to match Electrum Gate in this store: the same width, the same
|
||||||
|
layout, the same header, and the version number where you can see it. Buttons appear when you hover a
|
||||||
|
row, so a page you are only reading stays quiet. Explanatory paragraphs have moved into a new
|
||||||
|
"About this app" item in the menu, which also spells out what the relay can and cannot see.
|
||||||
|
|
||||||
|
|
||||||
|
Earlier releases:
|
||||||
|
|
||||||
|
|
||||||
|
0.4.0 fixed a status page that alternated between working and showing an error. It reached its helper
|
||||||
|
by a short name that another app in this store uses as well, so about half of its requests ended up
|
||||||
|
at the wrong app.
|
||||||
|
|
||||||
|
|
||||||
|
0.4.0 also dropped the wording that tied the app to one particular client. It is a general purpose
|
||||||
|
Evolu relay: any app built on Evolu can use it.
|
||||||
|
|
||||||
|
|
||||||
|
0.2.0 replaced the relay this app shipped with. Until 0.0.2 it packaged a vendor's own deployment,
|
||||||
|
which came with a quota manager and a PostgreSQL database and could not work outside that vendor's
|
||||||
|
service: clients skip the quota manager as soon as you point them at a relay of your own, while
|
||||||
|
that relay refuses every owner the quota manager never registered. 0.2.0 uses the relay from the
|
||||||
|
Evolu project instead. Measured, not assumed: a client synced to it and the data arrived. Three
|
||||||
|
containers became one relay plus a status page, and the database and its password are gone.
|
||||||
|
|
||||||
|
|
||||||
|
0.2.0 also added the status page behind your umbrelOS login, and control over who may sync.
|
||||||
|
|
||||||
|
|
||||||
|
Not verified yet: reading back from a second device, and whether every client platform can use a
|
||||||
|
relay of your own.
|
||||||
|
developer: "WhatsNext?"
|
||||||
|
website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
repo: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
support: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/issues
|
||||||
|
# De poort waarop umbrelOS deze app aanbiedt. Sinds 0.1.0 is dat de STATUSPAGINA
|
||||||
|
# en niet de relay: de app-proxy zet er de inlog van umbrelOS voor, en daar hoort
|
||||||
|
# een pagina wel achter en een relay niet. De cliënt wijst naar 3852, die de
|
||||||
|
# relay zelf publiceert. Zie docker-compose.yml en het plan Umbrelapp, PLAN.md §4h.
|
||||||
|
#
|
||||||
|
# Niet 4000, de eigen poort van de relay: die is een veelgebruikte poort en een
|
||||||
|
# botsing op de host merk je pas als de app niet start. Dat heeft bij Electrum
|
||||||
|
# Gate een keer een dag gekost, met 50002 tegen Fulcrum. 3851 sluit aan op de 3850
|
||||||
|
# van die app.
|
||||||
|
port: 3851
|
||||||
|
# Leeg laten bij een nieuw pakket; de plaatjes doet het store-team zelf.
|
||||||
|
gallery: []
|
||||||
|
path: ""
|
||||||
|
submitter: "WhatsNext?"
|
||||||
|
submission: https://sc.kamenier-hamer.nl/sysop/UmbrelApps
|
||||||
|
|
||||||
|
# ── Hieronder staat wat de voorgeschreven volgorde niet noemt ────────────────
|
||||||
|
#
|
||||||
|
# Het icoon, sinds 28-08-2026. Het bestand staat in deze repo en wordt door Gitea
|
||||||
|
# rauw uitgeleverd; dezelfde constructie als bij Electrum Gate. Bij inlevering in
|
||||||
|
# de officiële store moet deze regel juist weg, want die host iconen apart.
|
||||||
|
# Daarom staat hij als laatste: dan is het één regel schrappen.
|
||||||
|
icon: https://sc.kamenier-hamer.nl/sysop/UmbrelApps/raw/branch/main/whatsnext-evolu-relay/icon.png
|
||||||
|
|
||||||
|
# Wat niet in de back-up hoeft. Paden zijn relatief aan de app-datamap.
|
||||||
|
#
|
||||||
|
# Voor deze app is dat vrijwel niets, en dat is expres: de database ís de waarde
|
||||||
|
# van de app. Electrum Gate gebruikt dit veld wel, maar daar gaat het om een log
|
||||||
|
# en een statusbestand. Neem die keuze hier niet uit gewoonte over.
|
||||||
|
backupIgnore: []
|
||||||
Reference in new issue
Block a user