Het relay-programma: de relay van Evolu met onze eigen allowlist
tools/evolu-relay/src/ bevat nu een eigen programma dat createRelay uit @evolu/nodejs aanroept met de twee terugroepfuncties die het bedoelde uitbreidpunt vormen. De relay zelf komt uit npm en wordt niet nagebouwd of aangepast; de opstartvolgorde is overgenomen uit apps/relay/src/index.ts van Evolu zelf. De opzet is drie bestanden met een harde scheiding, en die scheiding is de reden dat hier iets te testen valt. policy.js bevat het beleid als pure functies: geen bestanden, geen netwerk, geen klok. store.js is de enige plek met schijf erin. index.js doet niets anders dan lezen, doorgeven en opslaan. Het beleid: de eerste eigenaar die zich meldt wordt geleerd, een schakelaar bepaalt of er nog nieuwe bij mogen, en een eigenaar is te blokkeren, alsnog toe te laten of te vergeten. Geweigerde pogingen worden onthouden voor de pagina, afgekapt op twintig, want elke poging is een id dat de ander zelf verzint. Een onleesbaar owners.json wordt opzij geschoven en de app gaat dan dicht in plaats van open: we weten dan niet wie er toegelaten was, en met de leerstand aan zou de eerstvolgende die verbindt de nieuwe eigenaar worden. Met test: node tests/test_limiter.mjs, 60 toetsen, en de toetsen gaan over de guards en niet over het gelukkige pad. De beslissende regel is muteertest gedaan en de juiste toets viel om: een geblokkeerde eigenaar mag er niet alsnog in doordat de leerstand aanstaat. Het bestand is .mjs omdat de repo-root geen package.json heeft en een .js daar als CommonJS gelezen zou worden. build.sh bouwt niet langer de repo van Trezor maar onze eigen Dockerfile, dus git is er niet meer voor nodig en de pin zit nu in package.json. De image is node:24-slim en niet alpine, want better-sqlite3 heeft binaries voor glibc en niet voor musl. Er is nog geen package-lock.json; het script waarschuwt daarvoor en het staat als taak. Onderweg bleek een aanname van vanmiddag fout: de 1 MB uit de gepubliceerde image geldt per schrijfactie en niet per eigenaar. Er valt dus geen labelgeschiedenis tegenaan te lopen. Dat is rechtgezet in het plan en in de naslag, en het getal is overgenomen als bewuste keuze met RELAY_MAX_WRITE_BYTES ernaast. Wat er niet in zit en ook niet gegokt is: data per eigenaar wissen. Het beleid kan een eigenaar vergeten, maar zijn berichten staan in de SQLite van de relay, en dat is andermans schema. 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:
@@ -64,9 +64,10 @@ gaat en zijn apps zelf vindt, dus over beide:
|
||||
python tests/test_appstore_vorm.py
|
||||
```
|
||||
|
||||
Geen testrunner en geen afhankelijkheden: het zijn losse scripts die 0 teruggeven als alles goed is. Alle
|
||||
drie horen bij "de suite" en draaien voor een commit; de uitvoer eindigt met een regel "N goed, M fout".
|
||||
Er is geen watch-modus; de suite kost minder dan een seconde.
|
||||
Geen testrunner en geen afhankelijkheden: het zijn losse scripts die 0 teruggeven als alles goed is. Ze
|
||||
horen samen met de test van Evolu Relay hieronder bij "de suite" en draaien voor een commit; de uitvoer
|
||||
eindigt bij alle vier met een regel "N goed, M fout". Er is geen watch-modus; de suite kost minder dan een
|
||||
seconde.
|
||||
|
||||
`test_appstore_vorm.py` toetst **expres niet** dat images op een digest gepind zijn. Dat is wél de regel,
|
||||
maar geen van de twee apps haalt hem vandaag, en een suite die altijd rood staat wordt niet gelezen. Hij
|
||||
@@ -107,16 +108,40 @@ dan een app die weigert en zegt waarom.
|
||||
|
||||
## Evolu Relay
|
||||
|
||||
**De relay-code is niet van ons.** Dit pakketteert `trezor/trezor-suite-sync`; er worden geen wijzigingen
|
||||
aan die software gedaan en er wordt niet bovenstrooms bijgedragen.
|
||||
### De tests draaien
|
||||
|
||||
**Het bouwrecept staat in `tools/evolu-relay/`, nooit in de app-map.** Trezor publiceert geen image, dus er
|
||||
is een bouwstap. Die hoort niet in `whatsnext-evolu-relay/`: een `Dockerfile` staat niet in de
|
||||
update-whitelist, dus bouwen-in-de-app kost bij elke nieuwe versie een deïnstallatie plus herinstallatie.
|
||||
Wat wij toevoegen aan hun Dockerfile is uitsluitend de **pin** op een commit; bouw hun stappen niet na.
|
||||
```
|
||||
node tests/test_limiter.mjs
|
||||
```
|
||||
|
||||
**Verhoog je de pin in `build.sh`, dan verhoog je ook `version` in het manifest.** Anders is er een nieuwe
|
||||
image en een oude installatie, zonder dat iets dat meldt.
|
||||
Dit is de vierde test van de suite en de enige die geen Python is. Hij toetst het toegangsbeleid uit
|
||||
`tools/evolu-relay/src/policy.js`: wie er op de relay mag schrijven, wat er met een onbekende eigenaar
|
||||
gebeurt, en of een geblokkeerde eigenaar er niet alsnog in komt doordat de leerstand aanstaat. Node 24 of
|
||||
hoger, geen afhankelijkheden, en het bestand is met opzet `.mjs`: in de repo-root is er geen `package.json`,
|
||||
dus een `.js` zou als CommonJS gelezen worden en de import falen.
|
||||
|
||||
**Het beleid staat los van de relay en dat is de reden dat dit te testen is.** `policy.js` bevat alleen
|
||||
pure functies: geen bestanden, geen netwerk, geen klok. Wat wél schijf raakt staat in `store.js`, en de
|
||||
relay zelf wordt in `index.js` alleen aangeroepen. Houd die scheiding aan; anders is er van deze test niets
|
||||
meer over.
|
||||
|
||||
### Architectuurregels
|
||||
|
||||
**De relay-code is niet van ons.** Dit gebruikt `@evolu/nodejs` uit npm; er worden geen wijzigingen aan die
|
||||
software gedaan en er wordt niet bovenstrooms bijgedragen. Wat wij toevoegen zijn de twee terugroepfuncties
|
||||
`isOwnerAllowed` en `isOwnerWithinQuota`, en dat is het bedoelde uitbreidpunt. **Bouw de relay niet na.**
|
||||
|
||||
**Het programma en het bouwrecept staan in `tools/evolu-relay/`, nooit in de app-map.** Een `Dockerfile`
|
||||
staat niet in de update-whitelist, dus bouwen-in-de-app kost bij elke nieuwe versie een deïnstallatie plus
|
||||
herinstallatie, en het zou de installatie minuten laten hangen.
|
||||
|
||||
**Verhoog je `VERSION` in `build.sh`, dan verhoog je ook `version` in het manifest.** Anders is er een
|
||||
nieuwe image en een oude installatie, zonder dat iets dat meldt. Houd die twee gelijk.
|
||||
|
||||
> **De app-map loopt achter op `tools/`.** Het programma hierboven is nieuw sinds 28-08-2026; de compose en
|
||||
> het manifest in `whatsnext-evolu-relay/` beschrijven nog het oude pakket met de relay van Trezor, een
|
||||
> Postgres en een quota-manager. Dat wordt vervangen in fase 5 van het plan **Umbrelapp**. De drie alinea's
|
||||
> hieronder over de app-proxy en de quota-manager gelden dus voor wat er staat, niet voor wat er komt.
|
||||
|
||||
**De app-proxy staat op `PROXY_AUTH_ADD: "false"` en dat is met opzet.** Trezor Suite is geen browser met
|
||||
een sessiecookie. Zet het niet "voor de veiligheid" terug: dan krijgt Suite een inlogpagina in plaats van
|
||||
|
||||
@@ -141,9 +141,11 @@ eigen image. Wat níet terugkomt is de Postgres, het wachtwoord en het onderhoud
|
||||
bouwrecept in `tools/evolu-relay/` blijft dus bestaan, maar het bouwt straks niet meer de repo van Trezor;
|
||||
het bouwt een klein eigen programma tegen `@evolu/nodejs`.
|
||||
|
||||
**Twee dingen om niet over te slaan.** De gepubliceerde image zet `isOwnerWithinQuota` op **1 MB per
|
||||
eigenaar** en laat `isOwnerAllowed` weg. Nemen wij die grens over zonder erbij na te denken, dan stopt het
|
||||
synchroniseren zodra de labelgeschiedenis daar tegenaan loopt, en dat merk je pas als het gebeurt. En de
|
||||
**Twee dingen om niet over te slaan.** De gepubliceerde image laat `isOwnerAllowed` weg en zet
|
||||
`isOwnerWithinQuota` op 1 MB **per schrijfactie**, niet per eigenaar: de relay geeft door hoeveel bytes déze
|
||||
actie nodig heeft en telt niets op. Trezor telt wel op, maar uit hun eigen tabel, en die hebben wij niet.
|
||||
Wij nemen die 1 MB over als bewuste keuze, instelbaar met `RELAY_MAX_WRITE_BYTES`: één labelwijziging is
|
||||
klein, dus een schrijfactie die daarboven uitkomt is eerder een fout of misbruik dan normaal gebruik. En de
|
||||
allowlist is **staat**: die hoort onder `${APP_DATA_DIR}/data/`, naast de relay-database en niet erin.
|
||||
|
||||
**Wat er bewust níet in gaat: een geheim pad in de URL.** De cliënt neemt een pad in de relay-URL letterlijk
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
> 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 (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
|
||||
onze twee terugroepfuncties. Het beleid staat apart in `policy.js` als pure functies, zodat het te toetsen
|
||||
is zonder relay en zonder Docker: `node tests/test_limiter.mjs`, 60 toetsen. De beslissende regel is
|
||||
muteertest gedaan, en de juiste toets viel om: een geblokkeerde eigenaar mag er niet alsnog in via de
|
||||
leerstand.
|
||||
|
||||
`build.sh` bouwt niet langer de repo van Trezor maar onze eigen Dockerfile, en Git is daarvoor niet meer
|
||||
nodig. Onderweg bleek dat de 1 MB uit de gepubliceerde image **per schrijfactie** geldt en niet per
|
||||
eigenaar; dat is op twee plekken rechtgezet waar het verkeerd stond.
|
||||
|
||||
**Geraakt:** `tools/evolu-relay/`, `tests/test_limiter.mjs`, `CLAUDE.md` en de documentatie van dit plan.
|
||||
**Tests:** alle vier groen (32, 54, 39 en 60 goed, 0 fout).
|
||||
|
||||
## 28-08-2026 - de proef is geslaagd: de kale relay werkt
|
||||
|
||||
**Trezor Suite op de desktop stuurde elf labels naar `docker.io/evoluhq/relay:latest` en die kwamen aan**:
|
||||
|
||||
@@ -41,18 +41,28 @@
|
||||
Nog niets van begonnen. De volgorde is bewust: eerst het programma dat de limiter draagt, dan de app
|
||||
eromheen, want de compose hangt af van wat dat programma nodig heeft.
|
||||
|
||||
- [ ] **Het eigen relay-programma schrijven.** Een klein Node-project dat `createRelay` uit `@evolu/nodejs`
|
||||
aanroept met onze twee functies. Niets van Evolu aanpassen en niets nabouwen. Zie [PLAN.md](PLAN.md)
|
||||
§4g en [Upstream-evolu-relay.md](../../../Referenties/Upstream-evolu-relay.md) §8
|
||||
- [ ] **Bewust een getal kiezen voor `isOwnerWithinQuota`.** De gepubliceerde image staat op 1 MB per
|
||||
eigenaar. Overnemen zonder nadenken betekent dat het synchroniseren stilvalt zodra de
|
||||
labelgeschiedenis daar tegenaan loopt
|
||||
- [ ] **De allowlist als staat onder `${APP_DATA_DIR}/data/`**, naast de relay-database en niet erin, met
|
||||
een `.gitkeep` voor de map
|
||||
- [x] **Het eigen relay-programma geschreven (28-08-2026).** `tools/evolu-relay/src/`: `policy.js` met het
|
||||
beleid als pure functies, `store.js` voor de staat op schijf, en `index.js` dat `createRelay`
|
||||
aanroept met de opstartvolgorde van Evolu zelf. Met test: `node tests/test_limiter.mjs`, 60 toetsen,
|
||||
en de beslissende regel is muteertest gedaan
|
||||
- [x] **Een getal gekozen voor `isOwnerWithinQuota`: 1 MB per schrijfactie**, instelbaar met
|
||||
`RELAY_MAX_WRITE_BYTES`. Onderweg bleek de aanname hieronder fout: die 1 MB van de gepubliceerde image
|
||||
is **per schrijfactie** en geen totaal per eigenaar. Er valt dus geen labelgeschiedenis tegenaan te
|
||||
lopen. Een totaal per eigenaar zou vragen dat we in de opslag van de relay kijken, en dat doen we niet
|
||||
- [x] **De allowlist als staat onder `${APP_DATA_DIR}/data/`**, naast de relay-database en niet erin:
|
||||
`owners.json`, atomair geschreven, en `command.json` als postbus voor de pagina
|
||||
- [ ] **Data per eigenaar wissen.** Nog niet gebouwd, en bewust niet gegokt: het beleid kan een eigenaar
|
||||
vergeten en blokkeren, maar zijn berichten staan in de SQLite van de relay
|
||||
(`evolu_message`, `evolu_history`, met `ownerId` als systeemkolom). Dat is schrijven in andermans
|
||||
schema, dus dat wil eerst nagekeken worden in `createRelaySqliteStorage` van Evolu
|
||||
- [ ] **Een `package-lock.json` maken en `npm install` in de Dockerfile omzetten naar `npm ci`.** De twee
|
||||
pakketten van Evolu staan exact vast, maar wat eronder hangt beweegt nu nog mee. `build.sh` waarschuwt
|
||||
hiervoor. Kan pas op een machine met netwerk en npm
|
||||
- [ ] **Wissen per eigenaar laten doen door het relay-proces zelf**, aangestuurd met een vlagbestand vanaf
|
||||
de pagina. Geen tweede container die langszij in de SQLite schrijft, en geen Docker-socket
|
||||
- [ ] **`tools/evolu-relay/build.sh` omschrijven:** niet meer de repo van Trezor bouwen maar het eigen
|
||||
programma. De pin blijft, en met de pin mee gaat `version` in het manifest omhoog
|
||||
- [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
|
||||
|
||||
@@ -329,11 +329,13 @@ haken hetzelfde kan zonder die machinerie.
|
||||
`apps/relay/src/index.ts` staat `isOwnerAllowed` **uitgecommentarieerd** (dus iedereen mag erin) en
|
||||
`isOwnerWithinQuota` op **1 MB per eigenaar**. Dat is geen theorie maar de image die wij draaien:
|
||||
|
||||
- **een kale relay is niet volledig ongelimiteerd.** Er zit een bovengrens op wat één eigenaar kan
|
||||
wegschrijven, en dat dempt misbruik als gratis opslag meer dan gedacht;
|
||||
- **maar 1 MB is ook een plafond voor de échte gebruiker.** Loopt de labelgeschiedenis daar tegenaan, dan
|
||||
stopt het synchroniseren, en dat merk je pas als het gebeurt. Wie zelf een relay bouwt, kiest dat getal
|
||||
dus bewust.
|
||||
- **die grens geldt per schrijfactie en niet per eigenaar.** De relay geeft door hoeveel bytes déze actie
|
||||
nodig heeft; er wordt niets opgeteld. Trezor telt wél op, maar dat doen ze zelf uit hun eigen tabel, en
|
||||
die tabel is precies wat een kale relay niet heeft. Een labelgeschiedenis kan hier dus niet tegenaan
|
||||
lopen;
|
||||
- **wat het wél tegenhoudt is één grote schrijfactie.** Dat dempt misbruik als gratis opslag, maar het is
|
||||
geen totaalquotum. Wie dat wil, moet in de opslag van de relay kijken hoeveel een eigenaar al gebruikt,
|
||||
en dat is schrijven noch lezen in je eigen schema.
|
||||
|
||||
De relay zet zijn data in een map `data` naast het programma (`mkdirSync` plus `process.chdir`), en dat is
|
||||
het volume `/app/data` uit de image. In de database zitten onder meer `evolu_message` en `evolu_history`, en
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 getoetst wordt: een geblokkeerde
|
||||
// eigenaar mag niet alsnog binnenkomen doordat de leerstand aanstaat.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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-08-28T12:00:00.000Z';
|
||||
const LATER = '2026-08-28T13:00:00.000Z';
|
||||
const EIGEN = 'owner-van-de-gebruiker';
|
||||
const VREEMD = 'owner-van-een-ander';
|
||||
|
||||
// ── De leerstand ──────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const vers = createEmptyState();
|
||||
toets('een verse staat staat in de leerstand', vers.learning === true);
|
||||
|
||||
const eerste = decideOwner(vers, EIGEN, NU);
|
||||
toets('de eerste eigenaar wordt toegelaten', eerste.allowed === true);
|
||||
toets('en onthouden als toegelaten', eerste.state.owners.length === 1);
|
||||
toets('met de reden dat hij geleerd is', eerste.reason === 'learned');
|
||||
|
||||
const nogEens = decideOwner(eerste.state, EIGEN, LATER);
|
||||
toets('dezelfde eigenaar blijft toegelaten', nogEens.allowed === true);
|
||||
toets('en komt er niet dubbel in', nogEens.state.owners.length === 1);
|
||||
toets('de reden is nu dat hij bekend is', nogEens.reason === 'known-allowed');
|
||||
}
|
||||
|
||||
// ── De leerstand uit ──────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
const dicht = applyCommand(geleerd, { action: 'set-learning', value: false }, NU).state;
|
||||
|
||||
const eigen = decideOwner(dicht, EIGEN, LATER);
|
||||
toets('met de leerstand uit mag de bekende eigenaar er nog steeds in', eigen.allowed === true);
|
||||
|
||||
const vreemd = decideOwner(dicht, VREEMD, LATER);
|
||||
toets('maar een onbekende wordt geweigerd', vreemd.allowed === false);
|
||||
toets('de weigering wordt onthouden voor de pagina', vreemd.state.rejected.length === 1);
|
||||
toets('en hij belandt niet in de allowlist', vreemd.state.owners.length === 1);
|
||||
}
|
||||
|
||||
// ── De guard die er het meest toe doet ────────────────────────────────────────
|
||||
|
||||
{
|
||||
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
const geblokkeerd = applyCommand(geleerd, { action: 'block', ownerId: EIGEN }, NU).state;
|
||||
|
||||
toets('de leerstand staat hier nog aan', geblokkeerd.learning === true);
|
||||
|
||||
const opnieuw = decideOwner(geblokkeerd, EIGEN, LATER);
|
||||
toets(
|
||||
'een geblokkeerde eigenaar komt er niet alsnog in via de leerstand',
|
||||
opnieuw.allowed === false,
|
||||
);
|
||||
toets('en de reden zegt dat hij geblokkeerd is', opnieuw.reason === 'known-blocked');
|
||||
}
|
||||
|
||||
// ── Onbruikbare invoer ────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const vers = createEmptyState();
|
||||
for (const rommel of ['', null, undefined, 42, {}, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
|
||||
const uitkomst = decideOwner(vers, rommel, NU);
|
||||
toets(`een onbruikbaar id (${typeof rommel}) wordt geweigerd`, uitkomst.allowed === false);
|
||||
toets('en wordt niet onthouden', uitkomst.state.owners.length === 0);
|
||||
toets('ook niet als geweigerde poging', uitkomst.state.rejected.length === 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── De lijst met weigeringen loopt niet vol ───────────────────────────────────
|
||||
|
||||
{
|
||||
let staat = { ...createEmptyState(), learning: false };
|
||||
for (let i = 0; i < MAX_REJECTED + 5; i += 1) {
|
||||
staat = decideOwner(staat, `indringer-${i}`, NU).state;
|
||||
}
|
||||
toets('de lijst met weigeringen wordt afgekapt', staat.rejected.length === MAX_REJECTED);
|
||||
toets('en de nieuwste staat vooraan', staat.rejected[0].id === `indringer-${MAX_REJECTED + 4}`);
|
||||
|
||||
const herhaling = decideOwner(staat, `indringer-${MAX_REJECTED + 4}`, LATER);
|
||||
toets('een herhaalde poging telt op', herhaling.state.rejected[0].attempts === 2);
|
||||
toets('en voegt geen tweede regel toe', herhaling.state.rejected.length === MAX_REJECTED);
|
||||
}
|
||||
|
||||
// ── De opdrachten van de pagina ───────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
|
||||
const uit = applyCommand(geleerd, { action: 'set-learning', value: false }, NU);
|
||||
toets('de leerstand gaat uit', uit.state.learning === false);
|
||||
toets('en dat is een wijziging', uit.changed === true);
|
||||
|
||||
const nogEensUit = applyCommand(uit.state, { action: 'set-learning', value: false }, NU);
|
||||
toets('nog een keer uitzetten verandert niets', nogEensUit.changed === false);
|
||||
toets('en is geen fout', nogEensUit.error === null);
|
||||
|
||||
const vergeten = applyCommand(geleerd, { action: 'forget', ownerId: EIGEN }, NU);
|
||||
toets('vergeten haalt de eigenaar uit de lijst', vergeten.state.owners.length === 0);
|
||||
|
||||
const onbekend = applyCommand(geleerd, { action: 'forget', ownerId: VREEMD }, NU);
|
||||
toets('een onbekende vergeten is een fout', onbekend.error === 'unknown-owner');
|
||||
toets('en verandert niets', onbekend.changed === false);
|
||||
|
||||
const blokkeerOnbekend = applyCommand(geleerd, { action: 'block', ownerId: VREEMD }, NU);
|
||||
toets('een onbekende blokkeren is een fout', blokkeerOnbekend.error === 'unknown-owner');
|
||||
|
||||
const dicht = { ...createEmptyState(), learning: false };
|
||||
const geweigerd = decideOwner(dicht, VREEMD, NU).state;
|
||||
const alsnog = applyCommand(geweigerd, { action: 'allow', ownerId: VREEMD }, LATER);
|
||||
toets('een geweigerde poging kan alsnog toegelaten worden', alsnog.state.owners.length === 1);
|
||||
toets('en verdwijnt dan uit de weigeringen', alsnog.state.rejected.length === 0);
|
||||
toets(
|
||||
'en mag er daarna in, ook met de leerstand uit',
|
||||
decideOwner(alsnog.state, VREEMD, LATER).allowed === true,
|
||||
);
|
||||
|
||||
toets(
|
||||
'een onbekende actie is een fout',
|
||||
applyCommand(geleerd, { action: 'sudo' }, NU).error === 'unknown-action',
|
||||
);
|
||||
toets(
|
||||
'een opdracht zonder vorm is een fout',
|
||||
applyCommand(geleerd, 'zet alles open', NU).error === 'malformed-command',
|
||||
);
|
||||
toets(
|
||||
'een leerstand-opdracht zonder boolean is een fout',
|
||||
applyCommand(geleerd, { action: 'set-learning', value: 'ja' }, NU).error === 'malformed-command',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lezen van schijf ──────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const goedeStaat = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
const rondgang = normalizeState(JSON.parse(JSON.stringify(goedeStaat)));
|
||||
toets('een eigen staat overleeft de rondgang door JSON', rondgang !== null);
|
||||
toets('met de eigenaar er nog in', rondgang.owners[0].id === EIGEN);
|
||||
|
||||
const rommel = [
|
||||
null,
|
||||
'tekst',
|
||||
[],
|
||||
{ version: 999, learning: true, owners: [], rejected: [] },
|
||||
{ version: 1, learning: 'ja', owners: [], rejected: [] },
|
||||
{ version: 1, learning: true, owners: {}, rejected: [] },
|
||||
{ version: 1, learning: true, owners: [{ id: '', allowed: true }], rejected: [] },
|
||||
{ version: 1, learning: true, owners: [{ id: EIGEN }], rejected: [] },
|
||||
];
|
||||
for (const invoer of rommel) {
|
||||
toets(`onbegrepen staat wordt geweigerd: ${JSON.stringify(invoer)}`, normalizeState(invoer) === null);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`${goed} goed, ${fout} fout`);
|
||||
process.exit(fout === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,35 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# De image voor de app whatsnext-evolu-relay.
|
||||
#
|
||||
# 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.
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
FROM node:24-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Eerst alleen het manifest, zodat een wijziging in `src/` de installatielaag niet
|
||||
# ongeldig maakt.
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
|
||||
# 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.
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
+40
-43
@@ -2,72 +2,69 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Bouwt de image voor de app whatsnext-evolu-relay.
|
||||
#
|
||||
# Waarom dit bestand bestaat: Trezor publiceert geen image. Hun werkproces duwt
|
||||
# naar een eigen Amazon ECR en op Docker Hub staat niets, dus er is geen
|
||||
# `docker pull` die iets oplevert. De broncode is wel publiek, met een Dockerfile
|
||||
# erin. Dit script is de brug: het haalt de broncode op een vastgezette commit en
|
||||
# bouwt hún Dockerfile.
|
||||
# Wat er gebouwd wordt is ONS programma: `src/index.js` roept `createRelay` uit
|
||||
# `@evolu/nodejs` aan met onze eigen toegangscontrole erin. De relay zelf komt
|
||||
# gewoon uit npm en wordt niet nagebouwd of aangepast.
|
||||
#
|
||||
# Waarom het NIET in de app-map staat: umbreld kopieert de hele app-map naar het
|
||||
# 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 of in de lokale Docker-opslag; de app verwijst alleen naar de tag.
|
||||
#
|
||||
# Waarom we hun Dockerfile gebruiken en geen eigen: dan hoeven we hun bouwstappen
|
||||
# niet na te bouwen en niet bij te houden. Wat wij toevoegen is uitsluitend de pin.
|
||||
# in een register; de app verwijst alleen naar de tag.
|
||||
#
|
||||
# Draaien: sh build.sh
|
||||
# Vereist: docker en git op de machine waar je bouwt.
|
||||
# Vereist: docker op de machine waar je bouwt. Git is niet meer nodig.
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -eu
|
||||
|
||||
# ── De pin ────────────────────────────────────────────────────────────────────
|
||||
# Dit is het enige wat je aanpast als je een nieuwere versie wil. Verhoog daarna
|
||||
# ook `version` in whatsnext-evolu-relay/umbrel-app.yml, anders rolt umbrelOS de
|
||||
# wijziging niet uit.
|
||||
UPSTREAM_REPO="https://github.com/trezor/trezor-suite-sync.git"
|
||||
UPSTREAM_COMMIT="c03a2043acec48d4a1bbafca65e94da33dce1edd"
|
||||
|
||||
# De tag waar docker-compose.yml van de app naar verwijst. De korte commit zit
|
||||
# erin, zodat je op het apparaat kunt zien wat er draait.
|
||||
# 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.
|
||||
#
|
||||
# Houd VERSION gelijk aan `version` in whatsnext-evolu-relay/umbrel-app.yml. Twee
|
||||
# redenen: je kunt op het apparaat zien wat er draait, en umbrelOS rolt een
|
||||
# wijziging niet uit als dat nummer niet omhoog gaat. Dat laatste heeft hier een
|
||||
# keer een dag gekost.
|
||||
VERSION="0.2.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 op het apparaat vastgesteld. Zie Umbrel-appstore-spec.md.
|
||||
IMAGE="sc.kamenier-hamer.nl/sysop/evolu-relay:c03a204"
|
||||
IMAGE="sc.kamenier-hamer.nl/sysop/evolu-relay:${VERSION}"
|
||||
|
||||
# ── Bouwen ────────────────────────────────────────────────────────────────────
|
||||
WORKDIR="$(mktemp -d)"
|
||||
cleanup() { rm -rf "$WORKDIR"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
RECEPT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
echo "Broncode ophalen op ${UPSTREAM_COMMIT}"
|
||||
# ── Controles vooraf ──────────────────────────────────────────────────────────
|
||||
# Beter hier hard falen dan een image bouwen die iets anders bevat dan je denkt.
|
||||
|
||||
# Niet `clone --branch`: dat pint op een naam die meebeweegt. Een fetch van één
|
||||
# commit haalt precies deze toestand op en niets anders. GitHub staat het ophalen
|
||||
# van een losse commit-sha toe; een Git-server die dat niet doet, geeft hier een
|
||||
# foutmelding in plaats van stil een andere versie te bouwen.
|
||||
git init --quiet "$WORKDIR"
|
||||
git -C "$WORKDIR" remote add origin "$UPSTREAM_REPO"
|
||||
git -C "$WORKDIR" fetch --quiet --depth 1 origin "$UPSTREAM_COMMIT"
|
||||
git -C "$WORKDIR" checkout --quiet FETCH_HEAD
|
||||
|
||||
# Controle dat we hebben wat we dachten. Zonder deze regel bouwt het script bij
|
||||
# een gewijzigde afspraak over sha's stil de verkeerde toestand.
|
||||
GEVONDEN="$(git -C "$WORKDIR" rev-parse HEAD)"
|
||||
if [ "$GEVONDEN" != "$UPSTREAM_COMMIT" ]; then
|
||||
echo "FOUT: opgehaald ${GEVONDEN}, verwacht ${UPSTREAM_COMMIT}" >&2
|
||||
if [ ! -f "${RECEPT}/package.json" ] || [ ! -f "${RECEPT}/src/index.js" ]; then
|
||||
echo "FOUT: package.json of src/index.js ontbreekt in ${RECEPT}" >&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
|
||||
|
||||
# ── Bouwen ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Bouwen als ${IMAGE}"
|
||||
docker build --tag "$IMAGE" "$WORKDIR"
|
||||
docker build --tag "$IMAGE" "$RECEPT"
|
||||
|
||||
echo
|
||||
echo "Klaar. De app verwijst naar deze tag:"
|
||||
echo "Klaar:"
|
||||
docker image inspect --format '{{.RepoTags}} {{.Id}}' "$IMAGE"
|
||||
echo
|
||||
echo "Bouwen is niet genoeg: de app verwijst naar het register, want umbreld kan"
|
||||
@@ -77,8 +74,8 @@ echo " docker login sc.kamenier-hamer.nl"
|
||||
echo " docker push ${IMAGE}"
|
||||
echo
|
||||
echo "Zet daarna de digest uit de push-uitvoer achter de tag in"
|
||||
echo "whatsnext-evolu-relay/docker-compose.yml, op beide services, en verhoog"
|
||||
echo "\`version\` in het manifest. Zonder die verhoging rolt umbrelOS het niet uit."
|
||||
echo "whatsnext-evolu-relay/docker-compose.yml, en zet \`version\` in het manifest"
|
||||
echo "op ${VERSION}. Zonder die verhoging rolt 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"
|
||||
|
||||
@@ -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,142 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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. New owners are refused until you ' +
|
||||
'enable learning again 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
if (decision.reason === 'learned') {
|
||||
console.log('[info] learned a new owner; turn learning off on the status page when done');
|
||||
}
|
||||
return decision.allowed;
|
||||
},
|
||||
|
||||
isOwnerWithinQuota: (_ownerId, requiredBytes) => requiredBytes <= maxWriteBytes,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,207 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
||||
// onbekende eigenaar.
|
||||
//
|
||||
// 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 geweigerde eigenaars 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.
|
||||
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,
|
||||
// Leerstand. Aan betekent: de eerstvolgende onbekende eigenaar wordt
|
||||
// toegelaten. Dit staat aan bij een verse installatie, want anders kan de
|
||||
// eigenaar zichzelf nooit aanmelden: Trezor Suite toont je `OwnerId` nergens.
|
||||
learning: true,
|
||||
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 (typeof raw.learning !== 'boolean') return null;
|
||||
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
|
||||
|
||||
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, learning: raw.learning, owners, rejected };
|
||||
};
|
||||
|
||||
const rememberRejected = (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,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.learning) {
|
||||
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }];
|
||||
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
state: { ...state, rejected: rememberRejected(state.rejected, ownerId, now) },
|
||||
reason: 'not-learning',
|
||||
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 'set-learning': {
|
||||
if (typeof command.value !== 'boolean') return unchanged('malformed-command');
|
||||
if (state.learning === command.value) return { state, changed: false, error: null };
|
||||
return { state: { ...state, learning: command.value }, changed: true, error: null };
|
||||
}
|
||||
|
||||
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.
|
||||
const owners = known
|
||||
? state.owners.map((owner) =>
|
||||
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
|
||||
)
|
||||
: [...state.owners, { id: command.ownerId, allowed: true, firstSeen: now, 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);
|
||||
Reference in New Issue
Block a user