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:
Harmen
2026-08-28 11:22:32 +02:00
co-authored by Claude Opus 5
parent a738f6c0f1
commit 576acd20dd
8 changed files with 963 additions and 147 deletions
+480
View File
@@ -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 &middot; 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>