Het pakket omgebouwd: kale relay, statuspagina, poorten omgedraaid
De app-map is niet langer de relay van Trezor met een Postgres en een quota-manager ernaast, maar de relay van Evolu met onze eigen allowlist eromheen. Drie containers werden relay, agent en nginx; het wachtwoord en de database zijn verdwenen. De poorten zijn omgedraaid en dat is de kern. Tot 0.0.2 hing de relay achter app_proxy en moest de inlog van umbrelOS dus uit, waardoor een statuspagina net zo onbeschermd zou zijn als de relay zelf. Nu hangt de pagina daar met de inlog aan, en publiceert de relay zijn eigen poort 3852 waar Zoraxy met TLS naartoe wijst. Dat is hetzelfde patroon als Electrum Gate met 50022. Niet 4000 op de host, want dat is een veelgebruikte poort en een botsing merk je pas als de app niet start. De agent 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. Hij weigert ook een tweede opdracht zolang de vorige er nog ligt, want overschrijven zou er stil een laten verdwijnen. De pagina volgt het ontwerpsysteem van Electrum Gate, zonder de Google Fonts-verwijzing daaruit: een app op een Umbrel hoort niet te wachten op een lettertype van buiten. Alles is met stringoptelling geschreven en zonder enig dollarteken, want umbreld haalt elke template door envsubst en zou een JavaScript-template-literal stilzwijgend leegmaken. De image is door de gebruiker gebouwd en geduwd; de compose is gepind op tag plus digest. Manifest naar 0.2.0, met een beschrijving en releaseNotes die kloppen met wat er nu draait in plaats van met het vorige pakket. Wat hier NIET mee bewezen is, en dat is meer dan gebruikelijk: de agent is alleen op syntaxis gecontroleerd, de pagina is nooit gerenderd, en of een opdracht van de pagina daadwerkelijk bij het relay-proces aankomt is niet gemeten. Dat kan alleen op het apparaat en staat als taak. Tests: alle vier groen (32, 54, 39 en 60 goed, 0 fout). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,23 @@
|
||||
> Chronologisch sessielog, nieuwste bovenaan. Kort: 3 tot 6 regels per entry. Wat er is gebeurd en waarom,
|
||||
> niet wat er nog moet: dat staat in [TAKEN.md](TAKEN.md).
|
||||
|
||||
## 28-08-2026 (avond) - het pakket is omgebouwd: relay, agent en pagina
|
||||
|
||||
De app-map is nu de kale relay met onze allowlist eromheen. De poorten zijn omgedraaid: de statuspagina
|
||||
hangt achter `app_proxy` mét de inlog van umbrelOS, de relay publiceert 3852 waar Zoraxy met TLS naartoe
|
||||
gaat wijzen. Postgres, het wachtwoord en de quota-manager zijn eruit.
|
||||
|
||||
De gebruiker heeft de image gebouwd en naar het Gitea-register geduwd; de compose is gepind op tag plus
|
||||
digest. Manifest naar 0.2.0, met een beschrijving en releaseNotes die niet langer over Trezor's relay en
|
||||
een quota-manager gaan.
|
||||
|
||||
**Alles van vanavond is ongetest gedrag.** De agent is alleen op syntaxis gecontroleerd, de pagina is nooit
|
||||
gerenderd, en of een opdracht van de pagina bij het relay-proces aankomt is niet gemeten. Dat is de
|
||||
eerstvolgende taak en die kan alleen op het apparaat.
|
||||
|
||||
**Geraakt:** de hele app-map, `tools/evolu-relay/build.sh` en de documentatie van dit plan.
|
||||
**Tests:** alle vier groen (32, 54, 39 en 60 goed, 0 fout).
|
||||
|
||||
## 28-08-2026 (later) - het relay-programma staat, met een test erop
|
||||
|
||||
`tools/evolu-relay/src/` bevat nu een eigen programma dat `createRelay` uit `@evolu/nodejs` aanroept met
|
||||
|
||||
@@ -63,13 +63,17 @@ eromheen, want de compose hangt af van wat dat programma nodig heeft.
|
||||
- [x] **`tools/evolu-relay/build.sh` omgeschreven (28-08-2026):** bouwt de eigen `Dockerfile` in plaats van
|
||||
de repo van Trezor te klonen. Git is niet meer nodig, de pin zit nu in `package.json`, en `VERSION`
|
||||
in het script hoort gelijk te zijn aan `version` in het manifest
|
||||
- [ ] **De compose omzetten:** pagina achter `app_proxy` met de inlog aan, relay op een eigen `ports:`.
|
||||
Postgres, wachtwoord en quota-manager eruit. Zie [PLAN.md](PLAN.md) §4h
|
||||
- [ ] **De statuspagina bouwen**, met de pagina en de agent van Electrum Gate als vertrekpunt en met
|
||||
hetzelfde ontwerpsysteem (`HomeGit/Docs/website-design-system.html`), maar zonder de Google
|
||||
Fonts-verwijzing daaruit. Toont of de relay draait, welke eigenaars bekend en toegelaten zijn, de
|
||||
omvang van de database en het laatste schrijfmoment. Draagt de schakelaar voor nieuwe eigenaars en de
|
||||
wisknop per eigenaar
|
||||
- [x] **De compose omgezet (28-08-2026):** pagina achter `app_proxy` mét de inlog, relay op host-poort
|
||||
3852. Postgres, wachtwoord en quota-manager eruit; drie containers werden relay, agent en nginx. De
|
||||
image is gepind op tag plus digest
|
||||
- [x] **De statuspagina gebouwd (28-08-2026):** `index.html.template`, `nginx.conf.template` en
|
||||
`agent.py.template`, met het ontwerpsysteem van Electrum Gate en zonder de Google Fonts-verwijzing.
|
||||
Toont of de relay draait, de omvang en het laatste schrijfmoment van de database, het adres dat je in
|
||||
Suite invult, en de eigenaars in drie lijsten met knoppen om te blokkeren, alsnog toe te laten of te
|
||||
vergeten. **Nog nooit in een browser gezien**
|
||||
- [ ] **De pagina en de agent op het apparaat verifiëren.** Alles hierboven is ongetest gedrag: de agent is
|
||||
alleen op syntaxis gecontroleerd, de pagina is nooit gerenderd, en of de opdrachten daadwerkelijk bij
|
||||
het relay-proces aankomen is niet gemeten. **Eigenaar: gebruiker**
|
||||
- [ ] **De app opnieuw installeren in plaats van updaten.** De gebruiker heeft de oude installatie op
|
||||
28-08-2026 weggehaald; dat is ook de nette weg, want niet elk bestand bereikt een bestaande
|
||||
installatie via een update
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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.
|
||||
#
|
||||
# LET OP: umbreld haalt dit bestand bij elke start door envsubst. Er mag dus geen
|
||||
# dollarteken in staan, ook niet in een regex of een tekst. Een accolade-variabele
|
||||
# die niet bestaat wordt leeg, en dat sloopt Python-code zonder foutmelding.
|
||||
# tests/test_appstore_vorm.py controleert dat.
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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"
|
||||
|
||||
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"))
|
||||
|
||||
# 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 = ("set-learning", "block", "allow", "forget")
|
||||
|
||||
MAX_BODY_BYTES = 4096
|
||||
MAX_OWNER_ID_LENGTH = 256
|
||||
|
||||
# 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 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 build_status():
|
||||
owners = read_owners()
|
||||
state = owners["state"] or {}
|
||||
return {
|
||||
"relay": {
|
||||
"reachable": probe["reachable"],
|
||||
"checked": probe["checked"],
|
||||
"publicPort": PUBLIC_PORT,
|
||||
},
|
||||
"owners": {
|
||||
"problem": owners["problem"],
|
||||
# Ontbreekt de staat, dan is 'learning' onbekend en niet 'false'. De
|
||||
# pagina hoort dat verschil te tonen: onbekend is een reden om te
|
||||
# kijken, uit is een keuze.
|
||||
"learning": state.get("learning"),
|
||||
"allowed": [
|
||||
entry
|
||||
for entry in state.get("owners", [])
|
||||
if isinstance(entry, dict) and entry.get("allowed") is True
|
||||
],
|
||||
"blocked": [
|
||||
entry
|
||||
for entry in state.get("owners", [])
|
||||
if isinstance(entry, dict) and entry.get("allowed") is False
|
||||
],
|
||||
"rejected": [
|
||||
entry for entry in state.get("rejected", []) if isinstance(entry, dict)
|
||||
],
|
||||
},
|
||||
"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"
|
||||
|
||||
if action == "set-learning":
|
||||
value = payload.get("value")
|
||||
if not isinstance(value, bool):
|
||||
return None, "waarde moet true of false zijn"
|
||||
return {"action": action, "value": value}, None
|
||||
|
||||
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 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 do_POST(self):
|
||||
if self.path.rstrip("/") not in ("/api/command", "/command"):
|
||||
self._send(404, {"error": "onbekend pad"})
|
||||
return
|
||||
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
self._send(400, {"error": "lengte ontbreekt"})
|
||||
return
|
||||
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
self._send(400, {"error": "lege of te grote opdracht"})
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._send(400, {"error": "onleesbare opdracht"})
|
||||
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()
|
||||
@@ -1,135 +1,129 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Evolu Relay - de sync-server van Trezor Suite op je eigen Umbrel.
|
||||
# Evolu Relay - je eigen sync-server voor de labels van Trezor Suite.
|
||||
#
|
||||
# Drie containers, twee images. De relay en de quota-manager komen uit dezélfde
|
||||
# image met een ander `command`: bovenstrooms is het één codebase met meerdere
|
||||
# startscripts. Dat is nagetrokken in de Dockerfile en de README van Trezor; zie
|
||||
# Docs/Referenties/Upstream-evolu-relay.md §2.
|
||||
# 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.
|
||||
# Trezor Suite 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_relay_1
|
||||
APP_PORT: 4000
|
||||
# Trezor Suite is geen browser met een sessiecookie. Achter de inlog van
|
||||
# umbrelOS zou het een inlogpagina krijgen in plaats van de relay, en dat
|
||||
# is niet op te lossen aan onze kant.
|
||||
#
|
||||
# Dit is geen omweg: het is het patroon dat de eigen nostr-relay-app van
|
||||
# Umbrel gebruikt, om precies dezelfde reden. Ook Gitea en Budibase in de
|
||||
# officiele store doen het zo.
|
||||
#
|
||||
# De prijs staat er wel bij: wie deze poort kan bereiken, bereikt de relay
|
||||
# zonder aanmelding. Wat de schade beperkt is dat de relay zelf elke
|
||||
# eigenaar weigert die geen limietenrij in de database heeft. Zet deze app
|
||||
# dus niet zonder meer open naar het internet; zie het masterplan
|
||||
# Bereikbaarheid.
|
||||
#
|
||||
# En let op de tweede weg naar binnen, die je niet ziet in deze compose:
|
||||
# umbrelOS maakt per app een Tor hidden service. Zonder inlog betekent dat
|
||||
# bereikbaar vanaf het internet, alleen beschermd doordat het .onion-adres
|
||||
# onraadbaar is. Bij Electrum Gate is dat geen bezwaar, want daar zit de
|
||||
# inlog van umbrelOS ervoor; hier is die er juist uit. Zet de hidden service
|
||||
# dus uit in umbrelOS tenzij je hem bewust wilt. Op 25-08-2026 stond hij bij
|
||||
# de eerste installatie gewoon aan.
|
||||
PROXY_AUTH_ADD: "false"
|
||||
APP_HOST: whatsnext-evolu-relay_server_1
|
||||
APP_PORT: 80
|
||||
|
||||
relay:
|
||||
# Gebouwd uit de broncode van Trezor door tools/evolu-relay/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 (docker-modem) en niet
|
||||
# via compose, dus een lokaal gebouwde tag is voor hem onbereikbaar en
|
||||
# `pull_policy` wordt nooit gelezen. Op 25-08-2026 op het apparaat
|
||||
# vastgesteld; zie Docs/Referenties/Umbrel-appstore-spec.md.
|
||||
# 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.
|
||||
#
|
||||
# De digest hoort erbij en niet alleen de tag: een tag kan opnieuw geduwd
|
||||
# worden en dan draait er iets anders dan hier staat.
|
||||
# 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 het manifest en aan VERSION in build.sh.
|
||||
#
|
||||
# Let op: dit is de digest van één architectuur (amd64), want er is alleen
|
||||
# amd64 geduwd. Voor de officiele store moet het een multi-arch index-digest
|
||||
# zijn met arm64 erin; zie het masterplan Publicatie-Relay.
|
||||
image: sc.kamenier-hamer.nl/sysop/evolu-relay:c03a204@sha256:2fe1e9e90e5cbc12d5ea6363df03bb132fddec0150283063b9103a09bdbe853a
|
||||
# Geduwd op 28-08-2026. Let op: dit is één architectuur (amd64), want er is
|
||||
# alleen amd64 gebouwd. Voor de officiele store hoort er een multi-arch
|
||||
# index-digest met arm64 in; zie het masterplan Publicatie-Relay.
|
||||
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.2.0@sha256:5b2b4228c24a7e8a12a99869ca39729f47a0a01d6a346c623b22d6e62f2918c2
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Bovenstrooms staat `CMD ["yarn", "start"]` in de Dockerfile, met daarbij een
|
||||
# eigen commentaar dat het misschien een van de twee specifieke scripts had
|
||||
# moeten zijn. Daarom hier expliciet, en niet vertrouwen op de standaard.
|
||||
command: ["yarn", "start-evolu-relay"]
|
||||
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"
|
||||
HEALTH_PORT: "4002"
|
||||
# dev of prod. Volgens .env.sample zet prod authenticatie aan; in de code
|
||||
# bepaalt die vlag alleen het logniveau en staan de autorisatiecontroles
|
||||
# onvoorwaardelijk aan. Een van de twee is achterhaald en dat is nog niet
|
||||
# uitgezocht. Hier staat prod, want dat is de veilige kant van die
|
||||
# onduidelijkheid: als het iets aanzet, willen we dat het aanstaat.
|
||||
SERVER_ENV: prod
|
||||
POSTGRES_GATE_HOST: whatsnext-evolu-relay_db_1
|
||||
POSTGRES_GATE_PORT: "5432"
|
||||
POSTGRES_GATE_USER: suite-sync
|
||||
POSTGRES_GATE_DB: suite-sync-gate
|
||||
# Door umbrelOS per installatie afgeleid. Nooit een letterlijke waarde:
|
||||
# deze repo is publiek.
|
||||
POSTGRES_GATE_PASSWORD: ${APP_PASSWORD}
|
||||
POSTGRES_GATE_SSL: "false"
|
||||
|
||||
quota-manager:
|
||||
# Zelfde image als relay, ander commando. Tag en digest moeten dus gelijk
|
||||
# blijven aan die hierboven.
|
||||
image: sc.kamenier-hamer.nl/sysop/evolu-relay:c03a204@sha256:2fe1e9e90e5cbc12d5ea6363df03bb132fddec0150283063b9103a09bdbe853a
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: ["yarn", "start-quota-manager"]
|
||||
# Waarom deze container er is, terwijl hij bij Trezor bij hun betaalde
|
||||
# hosting hoort: de relay weigert elke eigenaar zonder rij in de
|
||||
# limietentabel, en dit is wat die rijen aanmaakt. Er is geen HTTP-koppeling
|
||||
# tussen de twee; ze delen alleen de database. Zie Upstream-evolu-relay.md §3.
|
||||
environment:
|
||||
QUOTA_MANAGER_PORT: "4001"
|
||||
HEALTH_PORT: "4012"
|
||||
SERVER_ENV: prod
|
||||
POSTGRES_GATE_HOST: whatsnext-evolu-relay_db_1
|
||||
POSTGRES_GATE_PORT: "5432"
|
||||
POSTGRES_GATE_USER: suite-sync
|
||||
POSTGRES_GATE_DB: suite-sync-gate
|
||||
POSTGRES_GATE_PASSWORD: ${APP_PASSWORD}
|
||||
POSTGRES_GATE_SSL: "false"
|
||||
|
||||
db:
|
||||
# TODO pinnen op de multi-arch index-digest, met
|
||||
# `docker buildx imagetools inspect postgres:17-alpine`. Kan alleen op een
|
||||
# machine met Docker; staat als taak in het plan Umbrelapp.
|
||||
#
|
||||
# Niet `postgres` kaal zoals de compose van Trezor doet: dat is `latest` en
|
||||
# dus niet reproduceerbaar, en een grote-versiesprong van Postgres migreert
|
||||
# zijn datamap niet vanzelf. Dan start de database niet meer en is de data
|
||||
# alleen met handwerk terug te halen.
|
||||
image: postgres:17-alpine
|
||||
restart: on-failure
|
||||
environment:
|
||||
POSTGRES_USER: suite-sync
|
||||
POSTGRES_DB: suite-sync-gate
|
||||
POSTGRES_PASSWORD: ${APP_PASSWORD}
|
||||
# Een submap en niet de wortel van de mount. Postgres weigert een datamap
|
||||
# die al iets anders bevat, en een mount heeft op sommige bestandssystemen
|
||||
# een lost+found.
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
# 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 database een
|
||||
# bewaart en meeneemt in de back-up. In een volume zou de synchronisatie een
|
||||
# herinstallatie van de app niet overleven.
|
||||
- ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
# Op de eigen database en gebruiker, niet op `-d postgres` zoals
|
||||
# bovenstrooms: die database bestaat hier niet en dan is de controle groen
|
||||
# terwijl het verkeerde antwoord gegeven wordt.
|
||||
test: ["CMD-SHELL", "pg_isready -U suite-sync -d suite-sync-gate"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
- ${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:
|
||||
# TODO pinnen op de multi-arch index-digest, met
|
||||
# `docker buildx imagetools inspect python:3-alpine`. Geldt ook voor nginx
|
||||
# hieronder; staat als taak in het plan Umbrelapp.
|
||||
image: python:3-alpine
|
||||
restart: on-failure
|
||||
environment:
|
||||
RELAY_STATE_DIR: /var/lib/relay
|
||||
# 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 Trezor Suite invult.
|
||||
# Moet gelijk zijn aan de hostpoort hierboven.
|
||||
RELAY_PUBLIC_PORT: "3852"
|
||||
RELAY_API_PORT: "8000"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
volumes:
|
||||
# Door umbrelOS ingevuld uit agent.py.template bij het starten. Er staat
|
||||
# geen accolade-variabele in dat bestand, dus de invulling laat de
|
||||
# Python-code ongemoeid; de agent leest zijn instellingen uit de omgeving
|
||||
# hierboven. De test in tests/ controleert die aanname.
|
||||
- ${APP_DATA_DIR}/agent.py:/app/agent.py:ro
|
||||
# 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
|
||||
command:
|
||||
- python
|
||||
- /app/agent.py
|
||||
|
||||
server:
|
||||
image: nginx:alpine
|
||||
restart: on-failure
|
||||
# De agent moet er zijn voordat nginx start: de proxy_pass naar
|
||||
# http://agent:8000 wordt bij het starten opgelost en een onbekende naam laat
|
||||
# nginx afbreken.
|
||||
depends_on:
|
||||
- agent
|
||||
volumes:
|
||||
# Beide door umbrelOS ingevuld uit een .template bij het starten.
|
||||
- ${APP_DATA_DIR}/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ${APP_DATA_DIR}/index.html:/usr/share/nginx/html/index.html:ro
|
||||
# Het app-icoon, voor de kop van de pagina en het tabblad. Geen template,
|
||||
# dus dit bestand komt alleen bij een installatie mee en niet bij een
|
||||
# update; voor een plaatje dat vrijwel nooit wijzigt is dat goed genoeg.
|
||||
- ${APP_DATA_DIR}/icon.png:/usr/share/nginx/html/icon.png:ro
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
De statuspagina van Evolu Relay.
|
||||
|
||||
Zichtbare tekst is Engels: dit is een publieke app store en dit is wat een
|
||||
bezoeker ziet. Commentaar is Nederlands.
|
||||
|
||||
LET OP, en dit is de valstrik van dit bestand: umbreld haalt elke *.template
|
||||
bij het starten door envsubst. Er mag dus GEEN dollarteken in staan. Dat sluit
|
||||
ook JavaScript-template-literals uit, want die gebruiken accolades achter een
|
||||
dollarteken en zouden stilzwijgend leeg worden. Alles hieronder plakt strings
|
||||
dus met een plus aan elkaar. tests/test_appstore_vorm.py houdt dit dicht.
|
||||
|
||||
Het ontwerp volgt HomeGit/Docs/website-design-system.html, net als Electrum
|
||||
Gate, zodat de twee apps naast elkaar op hetzelfde dashboard herkenbaar zijn.
|
||||
Wat er bewust NIET uit overgenomen is: de verwijzing naar Google Fonts. Een app
|
||||
op een Umbrel hoort niet te wachten op een lettertype van buiten.
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
<html lang="en" data-theme="dark" data-accent="orange">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Evolu Relay</title>
|
||||
<link rel="icon" href="icon.png">
|
||||
<style>
|
||||
:root {
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-full: 999px;
|
||||
--font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', ui-monospace, Menlo, Consolas, monospace;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-accent="orange"] {
|
||||
--bg: #080808;
|
||||
--bg-elevated: #181818;
|
||||
--bg-raised: #242424;
|
||||
--border: rgba(255,255,255,0.18);
|
||||
--text-primary: #f2f2f2;
|
||||
--text-sec: #a3a3a3;
|
||||
--text-ter: #6a6a6a;
|
||||
--accent: #FF6B00;
|
||||
--accent-soft: rgba(255,107,0,0.16);
|
||||
--pos: #FF8124;
|
||||
--pos-bg: rgba(255,107,0,0.18);
|
||||
--neg: #ff5a5a;
|
||||
--neg-bg: rgba(255,69,69,0.18);
|
||||
--neutral-bg: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"][data-accent="orange"] {
|
||||
--bg: #ffffff;
|
||||
--bg-elevated: #f7f7f7;
|
||||
--bg-raised: #f0f0f0;
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--text-primary: #0a0a0a;
|
||||
--text-sec: #888888;
|
||||
--text-ter: #bbbbbb;
|
||||
--accent: #E05500;
|
||||
--accent-soft: rgba(224,85,0,0.08);
|
||||
--pos: #D04800;
|
||||
--pos-bg: rgba(224,85,0,0.08);
|
||||
--neg: #cc2222;
|
||||
--neg-bg: rgba(204,34,34,0.08);
|
||||
--neutral-bg: rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 16px 64px;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-base);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap { max-width: 780px; margin: 0 auto; }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
header img { width: 40px; height: 40px; border-radius: var(--radius-sm); }
|
||||
header h1 { font-size: 1.35rem; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
|
||||
header .spacer { flex: 1; }
|
||||
|
||||
.sub { color: var(--text-sec); margin: 0 0 24px; font-size: 0.92rem; }
|
||||
|
||||
.theme-toggle {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-sec);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-ter);
|
||||
margin: 0 0 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-row { display: flex; flex-wrap: wrap; gap: 24px; }
|
||||
.stat { min-width: 130px; }
|
||||
.stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-ter); }
|
||||
.stat-val { font-size: 1.15rem; font-weight: 600; margin-top: 2px; }
|
||||
.stat-val.mono { font-family: var(--font-mono); font-size: 0.95rem; }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pill.ok { background: var(--pos-bg); color: var(--pos); }
|
||||
.pill.bad { background: var(--neg-bg); color: var(--neg); }
|
||||
.pill.unknown { background: var(--neutral-bg); color: var(--text-sec); }
|
||||
|
||||
.url-box {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hint { color: var(--text-sec); font-size: 0.85rem; margin-top: 10px; }
|
||||
|
||||
.switch-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.switch-row .grow { flex: 1; min-width: 200px; }
|
||||
|
||||
button.action {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 8px 18px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s var(--ease-out);
|
||||
}
|
||||
button.action:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
button.action:disabled { opacity: 0.45; cursor: default; }
|
||||
button.action.quiet { background: transparent; border-color: var(--border); color: var(--text-sec); }
|
||||
button.action.danger { background: var(--neg-bg); border-color: var(--neg); color: var(--neg); }
|
||||
|
||||
.owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.owner:first-of-type { border-top: none; }
|
||||
.owner .id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
.owner .meta { color: var(--text-ter); font-size: 0.75rem; }
|
||||
.owner .buttons { display: flex; gap: 8px; }
|
||||
|
||||
.empty { color: var(--text-ter); font-size: 0.88rem; font-style: italic; }
|
||||
|
||||
.notice {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.88rem;
|
||||
display: none;
|
||||
}
|
||||
.notice.show { display: block; }
|
||||
.notice.warn { background: var(--neg-bg); color: var(--neg); }
|
||||
.notice.info { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-top: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<img src="icon.png" alt="">
|
||||
<h1>Evolu Relay</h1>
|
||||
<span class="spacer"></span>
|
||||
<button class="theme-toggle" id="theme">Light</button>
|
||||
</header>
|
||||
<p class="sub">Your own sync server for Trezor Suite labels. Nothing leaves your machine.</p>
|
||||
|
||||
<div class="notice" id="notice"></div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Relay</h2>
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Status</div>
|
||||
<div class="stat-val"><span class="pill unknown" id="relay-state">checking</span></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Stored data</div>
|
||||
<div class="stat-val mono" id="db-size">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Last write</div>
|
||||
<div class="stat-val mono" id="db-modified">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Point Trezor Suite here</h2>
|
||||
<div class="url-box" id="relay-url">-</div>
|
||||
<p class="hint">
|
||||
In Trezor Suite, open the developer settings and set the custom relay URL. Leave the quota manager
|
||||
URL empty: Suite ignores it once a custom relay is set. The address must start with http or https;
|
||||
Suite rejects ws.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>New owners</h2>
|
||||
<div class="switch-row">
|
||||
<div class="grow">
|
||||
<div class="stat-val"><span class="pill unknown" id="learning-state">unknown</span></div>
|
||||
<p class="hint" id="learning-hint">
|
||||
While this is open, the next device that connects is accepted and remembered. Close it once your
|
||||
own devices are paired.
|
||||
</p>
|
||||
</div>
|
||||
<button class="action" id="learning-toggle" disabled>Loading</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Accepted owners</h2>
|
||||
<div id="allowed"><p class="empty">None yet.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Blocked owners</h2>
|
||||
<div id="blocked"><p class="empty">None.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Refused attempts</h2>
|
||||
<div id="rejected"><p class="empty">None.</p></div>
|
||||
<p class="hint">
|
||||
Devices that tried to connect while new owners were closed. Only the twenty most recent are kept.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer>Evolu Relay on umbrelOS · WhatsNext?</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var root = document.documentElement;
|
||||
var noticeBox = document.getElementById('notice');
|
||||
var busy = false;
|
||||
|
||||
// ── Thema ──────────────────────────────────────────────────────────────────
|
||||
var themeButton = document.getElementById('theme');
|
||||
var applyTheme = function (theme) {
|
||||
root.setAttribute('data-theme', theme);
|
||||
themeButton.textContent = theme === 'dark' ? 'Light' : 'Dark';
|
||||
try { localStorage.setItem('evolu-relay-theme', theme); } catch (error) { /* privémodus */ }
|
||||
};
|
||||
try {
|
||||
var saved = localStorage.getItem('evolu-relay-theme');
|
||||
if (saved === 'light' || saved === 'dark') applyTheme(saved);
|
||||
} catch (error) { /* privémodus */ }
|
||||
themeButton.addEventListener('click', function () {
|
||||
applyTheme(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
// ── Hulpjes ────────────────────────────────────────────────────────────────
|
||||
var notice = function (text, kind) {
|
||||
noticeBox.textContent = text;
|
||||
noticeBox.className = 'notice show ' + (kind || 'info');
|
||||
};
|
||||
var clearNotice = function () { noticeBox.className = 'notice'; };
|
||||
|
||||
var formatBytes = function (bytes) {
|
||||
if (typeof bytes !== 'number') return '-';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
var formatTime = function (value) {
|
||||
if (typeof value !== 'string') return 'never';
|
||||
var when = new Date(value);
|
||||
if (isNaN(when.getTime())) return 'never';
|
||||
return when.toLocaleString();
|
||||
};
|
||||
|
||||
var setPill = function (element, text, kind) {
|
||||
element.textContent = text;
|
||||
element.className = 'pill ' + kind;
|
||||
};
|
||||
|
||||
var shorten = function (ownerId) {
|
||||
if (ownerId.length <= 24) return ownerId;
|
||||
return ownerId.slice(0, 12) + '…' + ownerId.slice(-8);
|
||||
};
|
||||
|
||||
// ── Opdrachten ─────────────────────────────────────────────────────────────
|
||||
var send = function (command) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
clearNotice();
|
||||
fetch('api/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command)
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) { return { ok: response.ok, body: body }; });
|
||||
}).then(function (result) {
|
||||
if (!result.ok) {
|
||||
notice('The relay refused that: ' + (result.body.error || 'unknown reason'), 'warn');
|
||||
return;
|
||||
}
|
||||
notice('Sent. The relay applies changes within a few seconds.', 'info');
|
||||
}).catch(function () {
|
||||
notice('Could not reach the agent.', 'warn');
|
||||
}).then(function () {
|
||||
busy = false;
|
||||
window.setTimeout(refresh, 2500);
|
||||
});
|
||||
};
|
||||
|
||||
var ownerRow = function (entry, buttons) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'owner';
|
||||
|
||||
var id = document.createElement('div');
|
||||
id.className = 'id';
|
||||
id.textContent = shorten(entry.id);
|
||||
id.title = entry.id;
|
||||
row.appendChild(id);
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
var parts = [];
|
||||
if (entry.firstSeen) parts.push('first seen ' + formatTime(entry.firstSeen));
|
||||
if (entry.lastSeen) parts.push('last seen ' + formatTime(entry.lastSeen));
|
||||
if (entry.attempts) parts.push(entry.attempts + ' attempts');
|
||||
meta.textContent = parts.join(' · ');
|
||||
row.appendChild(meta);
|
||||
|
||||
var holder = document.createElement('div');
|
||||
holder.className = 'buttons';
|
||||
buttons.forEach(function (spec) {
|
||||
var button = document.createElement('button');
|
||||
button.className = 'action ' + (spec.style || 'quiet');
|
||||
button.textContent = spec.label;
|
||||
button.addEventListener('click', function () { send(spec.command(entry.id)); });
|
||||
holder.appendChild(button);
|
||||
});
|
||||
row.appendChild(holder);
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
var fillList = function (element, entries, emptyText, buttons) {
|
||||
element.textContent = '';
|
||||
if (!entries.length) {
|
||||
var empty = document.createElement('p');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = emptyText;
|
||||
element.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
entries.forEach(function (entry) { element.appendChild(ownerRow(entry, buttons)); });
|
||||
};
|
||||
|
||||
// ── De ronde ───────────────────────────────────────────────────────────────
|
||||
var refresh = function () {
|
||||
fetch('api/status', { cache: 'no-store' }).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (status) {
|
||||
var relayPill = document.getElementById('relay-state');
|
||||
if (status.relay.reachable === true) setPill(relayPill, 'running', 'ok');
|
||||
else if (status.relay.reachable === false) setPill(relayPill, 'not responding', 'bad');
|
||||
else setPill(relayPill, 'unknown', 'unknown');
|
||||
|
||||
document.getElementById('db-size').textContent = formatBytes(status.database.bytes);
|
||||
document.getElementById('db-modified').textContent = formatTime(status.database.modified);
|
||||
|
||||
document.getElementById('relay-url').textContent =
|
||||
'http://' + window.location.hostname + ':' + status.relay.publicPort;
|
||||
|
||||
var learningPill = document.getElementById('learning-state');
|
||||
var toggle = document.getElementById('learning-toggle');
|
||||
if (status.owners.learning === true) {
|
||||
setPill(learningPill, 'open', 'ok');
|
||||
toggle.textContent = 'Close';
|
||||
toggle.disabled = false;
|
||||
} else if (status.owners.learning === false) {
|
||||
setPill(learningPill, 'closed', 'unknown');
|
||||
toggle.textContent = 'Open';
|
||||
toggle.disabled = false;
|
||||
} else {
|
||||
setPill(learningPill, 'unknown', 'unknown');
|
||||
toggle.textContent = 'Loading';
|
||||
toggle.disabled = true;
|
||||
}
|
||||
toggle.onclick = function () {
|
||||
send({ action: 'set-learning', value: status.owners.learning !== true });
|
||||
};
|
||||
|
||||
fillList(document.getElementById('allowed'), status.owners.allowed, 'None yet.', [
|
||||
{ label: 'Block', style: 'danger', command: function (id) { return { action: 'block', ownerId: id }; } },
|
||||
{ label: 'Forget', command: function (id) { return { action: 'forget', ownerId: id }; } }
|
||||
]);
|
||||
|
||||
fillList(document.getElementById('blocked'), status.owners.blocked, 'None.', [
|
||||
{ label: 'Allow', command: function (id) { return { action: 'allow', ownerId: id }; } },
|
||||
{ label: 'Forget', command: function (id) { return { action: 'forget', ownerId: id }; } }
|
||||
]);
|
||||
|
||||
fillList(document.getElementById('rejected'), status.owners.rejected, 'None.', [
|
||||
{ label: 'Allow', command: function (id) { return { action: 'allow', ownerId: id }; } }
|
||||
]);
|
||||
|
||||
if (status.owners.problem === 'onleesbaar') {
|
||||
notice('The owner list could not be read and was moved aside. New owners are refused until you '
|
||||
+ 'open them again above.', 'warn');
|
||||
} else if (status.pendingCommand) {
|
||||
notice('A command is waiting for the relay to pick it up.', 'info');
|
||||
} else if (noticeBox.className.indexOf('warn') === -1) {
|
||||
clearNotice();
|
||||
}
|
||||
}).catch(function () {
|
||||
setPill(document.getElementById('relay-state'), 'agent unreachable', 'bad');
|
||||
});
|
||||
};
|
||||
|
||||
refresh();
|
||||
window.setInterval(refresh, 5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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 is een template, en dat is bewust: umbrelOS ververst bij een update alleen
|
||||
# een whitelist van bestanden, en *.template staat daarin. Bij elke start vult
|
||||
# umbrelOS de omgevingsvariabelen in en schrijft het resultaat weg als nginx.conf.
|
||||
#
|
||||
# Let op bij het wijzigen: gebruik hier geen nginx-variabelen met een dollarteken,
|
||||
# zoals de gebruikelijke in een log_format. De invulling vervangt elke
|
||||
# accolade-variabele en zou die stilzwijgend leegmaken. Daarom staat access_log
|
||||
# uit.
|
||||
|
||||
user nginx;
|
||||
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, en dat is geen vergetelheid: die zouden een
|
||||
# nginx-variabele vragen en die haalt de template-invulling weg. De agent
|
||||
# heeft ze niet nodig.
|
||||
location /api/ {
|
||||
proxy_pass http://agent: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ manifestVersion: 1
|
||||
id: whatsnext-evolu-relay
|
||||
category: bitcoin
|
||||
name: Evolu Relay
|
||||
version: "0.0.2"
|
||||
version: "0.2.0"
|
||||
tagline: Sync your Trezor Suite labels through your own machine
|
||||
description: >-
|
||||
Trezor Suite can sync the labels and account names you give your addresses across your
|
||||
@@ -24,43 +24,47 @@ description: >-
|
||||
can see that you are syncing at all.
|
||||
|
||||
|
||||
This is Trezor's own relay, not a reimplementation: it is built from the source they publish
|
||||
as trezor/trezor-suite-sync, pinned to a specific commit.
|
||||
This runs the relay from the Evolu project, the sync layer Trezor Suite is built on, taken
|
||||
straight from the package they publish. Nothing about it is reimplemented here. What this app
|
||||
adds is the part a self-hosted relay needs and an open one does not: control over who may use
|
||||
it.
|
||||
|
||||
|
||||
The tile opens a status page. It shows whether the relay is running, how much it stores, and
|
||||
which devices are allowed. New devices are accepted one at a time: the first owner that
|
||||
connects is remembered, and you close the door again once your own devices are paired. Any
|
||||
owner can be blocked or removed later from that same page.
|
||||
|
||||
|
||||
Point Trezor Suite at this Umbrel to use it. Away from home you will need a way in, such as
|
||||
Tailscale or a reverse proxy with your own domain.
|
||||
|
||||
|
||||
Two things to know before you install. The relay has no web interface, so the tile opens the
|
||||
relay itself rather than a page for you to read. And it only serves an owner that has a
|
||||
storage limit registered in its database, which is what the included quota manager is for;
|
||||
if syncing is refused, that registration is the place to look.
|
||||
releaseNotes: >-
|
||||
First release that can actually be installed. 0.0.1 pointed at an image that only existed
|
||||
on the machine it was built on, and the install failed before anything started: umbrel
|
||||
fetches every image from a registry, so a local build is out of reach. The image now comes
|
||||
from a registry and is pinned to its digest.
|
||||
A different relay, and a much smaller app. 0.0.2 packaged the relay Trezor runs for their own
|
||||
hosted service, with a quota manager and a PostgreSQL database beside it. That combination
|
||||
cannot work on your own machine: Suite skips the quota manager as soon as you point it at a
|
||||
relay of your own, while that relay refuses every owner the quota manager never registered.
|
||||
|
||||
|
||||
Packages Trezor's Evolu Relay, the quota manager it needs, and a PostgreSQL database, all on
|
||||
your own machine.
|
||||
This release uses the relay from the Evolu project instead, which Trezor Suite talks to
|
||||
directly. Measured, not assumed: Suite sent labels to it and they arrived. The database and
|
||||
the quota manager are gone, and three containers became one relay plus a status page.
|
||||
|
||||
|
||||
Built from source pinned to commit c03a204 of trezor/trezor-suite-sync. There is no image
|
||||
published by Trezor, so the image is built from their Dockerfile; see tools/evolu-relay in
|
||||
the app store repository.
|
||||
New: a status page behind your umbrelOS login, and control over who may sync. The first owner
|
||||
that connects is remembered and accepted; after that you close the door, and anything new is
|
||||
refused until you open it again.
|
||||
|
||||
|
||||
Not verified yet: whether Trezor Suite accepts this address, and what has to happen before
|
||||
an owner is allowed to sync.
|
||||
Not verified yet: reading back from a second device, and whether the iOS app can use a relay
|
||||
of your own at all.
|
||||
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, en tegelijk de poort waar Trezor
|
||||
# Suite naartoe wijst: de app-proxy staat met PROXY_AUTH_ADD op "false", dus wat
|
||||
# hier binnenkomt gaat rechtstreeks naar de relay. Zie docker-compose.yml.
|
||||
# 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. Trezor Suite 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
|
||||
|
||||
Reference in New Issue
Block a user