| 1 | /**
|
|---|
| 2 | * Een kudde ward-daemons, om de guardian met een CASELOAD te kunnen testen.
|
|---|
| 3 | *
|
|---|
| 4 | * Aanleiding (Bart, 8-8): ik noemde "tientallen hulpvragen bij een guardian" een
|
|---|
| 5 | * randgeval en zette het op P3. Bart: "precies de jeugdzorgmedewerker." Dat is
|
|---|
| 6 | * geen uitzondering maar een beroep, en alles wat we bouwden -- de wardlijst, de
|
|---|
| 7 | * poortenpanelen, de hulpvragen -- is ontworpen voor iemand met twee kinderen.
|
|---|
| 8 | * Deze farm is de maat die ontbrak.
|
|---|
| 9 | *
|
|---|
| 10 | * ECHT REMOTE, en dat is geen detail. De wards wonen op een ANDERE origin dan
|
|---|
| 11 | * dev.klonkt.com. Zou je ze onder dev zelf hangen, dan ziet Klonkt ze als lokaal
|
|---|
| 12 | * (het co-locatie-pad kijkt of de URI met PUBLIC_BASE_URL begint) en test je de
|
|---|
| 13 | * kortsluitroute in plaats van de federatie. Dan meet je het verkeerde.
|
|---|
| 14 | *
|
|---|
| 15 | * Wat dit WEL is: honderd AP-actors die de FEP-633c-handshake kunnen aannemen en
|
|---|
| 16 | * een hulpvraag kunnen sturen. Wat het NIET is: een Klonkt. Geen opslag van
|
|---|
| 17 | * timelines, geen weergave, geen C2S. Precies genoeg om aan de andere kant van
|
|---|
| 18 | * de lijn te staan.
|
|---|
| 19 | *
|
|---|
| 20 | * Sleutels staan in state.json en worden EEN keer gemaakt. Weggooien betekent
|
|---|
| 21 | * dat elke ward een vreemde wordt voor dev, en dat de handshake opnieuw moet.
|
|---|
| 22 | *
|
|---|
| 23 | * node farm.mjs # start op poort 3060
|
|---|
| 24 | * WARDS=100 PORT=3060 node farm.mjs
|
|---|
| 25 | */
|
|---|
| 26 |
|
|---|
| 27 | import http from 'node:http';
|
|---|
| 28 | import crypto from 'node:crypto';
|
|---|
| 29 | import fs from 'node:fs';
|
|---|
| 30 | import path from 'node:path';
|
|---|
| 31 | import { fileURLToPath } from 'node:url';
|
|---|
| 32 |
|
|---|
| 33 | const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 34 | const STATE = path.join(HERE, 'state.json');
|
|---|
| 35 | // GEEN TLS, en dat hoort ook zo (Barts vraag, 8-8: "waarom via https? gewoon
|
|---|
| 36 | // localhost ::1"). Een certificaat en een publieke hostname toevoegen aan een
|
|---|
| 37 | // doos die ook andermans sites draait, om honderd neptestkinderen te kunnen
|
|---|
| 38 | // bereiken, is de verkeerde prijs. Klonkt laat dit adres door via AP_ALLOW_HOSTS
|
|---|
| 39 | // -- een precies host:poort-paar, geen "loopback mag"-vlag.
|
|---|
| 40 | //
|
|---|
| 41 | // EN HET TOETST IETS DAT WE TOCH MOESTEN KUNNEN: http met HTTP-signatures, zonder
|
|---|
| 42 | // TLS. Dat is een echt federatiegeval (interne netwerken, onion), en tot nu toe
|
|---|
| 43 | // was het nergens uitgeprobeerd.
|
|---|
| 44 | const BASE = process.env.BASE || 'http://[::1]:3060';
|
|---|
| 45 | const PORT = Number(process.env.PORT || 3060);
|
|---|
| 46 | const N = Number(process.env.WARDS || 100);
|
|---|
| 47 | const HOST = new URL(BASE).host;
|
|---|
| 48 |
|
|---|
| 49 | // ── Staat ────────────────────────────────────────────────────────────────
|
|---|
| 50 | // Een plat bestand. Dit is een testkudde, geen instance: als de staat weg is
|
|---|
| 51 | // begin je opnieuw, en dat hoort ook zo -- anders sluipt er toestand in waarvan
|
|---|
| 52 | // niemand meer weet waar hij vandaan kwam.
|
|---|
| 53 |
|
|---|
| 54 | function laadStaat() {
|
|---|
| 55 | try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { wards: {} }; }
|
|---|
| 56 | }
|
|---|
| 57 | function bewaarStaat(s) {
|
|---|
| 58 | fs.writeFileSync(STATE, JSON.stringify(s, null, 2));
|
|---|
| 59 | }
|
|---|
| 60 |
|
|---|
| 61 | const staat = laadStaat();
|
|---|
| 62 |
|
|---|
| 63 | function ward(n) {
|
|---|
| 64 | const naam = `w${String(n).padStart(3, '0')}`;
|
|---|
| 65 | if (!staat.wards[naam]) {
|
|---|
| 66 | const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
|---|
| 67 | modulusLength: 2048,
|
|---|
| 68 | publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|---|
| 69 | privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|---|
| 70 | });
|
|---|
| 71 | staat.wards[naam] = { naam, publicKey, privateKey, guardians: [], offers: {}, log: [] };
|
|---|
| 72 | bewaarStaat(staat);
|
|---|
| 73 | }
|
|---|
| 74 | return staat.wards[naam];
|
|---|
| 75 | }
|
|---|
| 76 |
|
|---|
| 77 | const uriVan = (naam) => `${BASE}/u/${naam}`;
|
|---|
| 78 | const naamUit = (uri) => {
|
|---|
| 79 | const m = String(uri || '').match(/\/u\/(w\d{3})\/?$/);
|
|---|
| 80 | return m ? m[1] : null;
|
|---|
| 81 | };
|
|---|
| 82 |
|
|---|
| 83 | // ── Ondertekenen ─────────────────────────────────────────────────────────
|
|---|
| 84 | // (request-target) host date digest -- wat Mastodon stuurt en wat Klonkt
|
|---|
| 85 | // verwacht. Digest is verplicht zodra er een body is: zonder ondertekende
|
|---|
| 86 | // digest bewijst een handtekening niets over de inhoud.
|
|---|
| 87 |
|
|---|
| 88 | async function bezorg(w, inbox, activity) {
|
|---|
| 89 | const body = JSON.stringify(activity);
|
|---|
| 90 | const u = new URL(inbox);
|
|---|
| 91 | const date = new Date().toUTCString();
|
|---|
| 92 | const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
|
|---|
| 93 | const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
|
|---|
| 94 | const signature = crypto.sign('sha256', Buffer.from(signingString), w.privateKey).toString('base64');
|
|---|
| 95 | const sig = `keyId="${uriVan(w.naam)}#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
|
|---|
| 96 | const r = await fetch(inbox, {
|
|---|
| 97 | method: 'POST',
|
|---|
| 98 | headers: {
|
|---|
| 99 | 'Content-Type': 'application/activity+json',
|
|---|
| 100 | Accept: 'application/activity+json',
|
|---|
| 101 | Date: date, Digest: digest, Signature: sig,
|
|---|
| 102 | },
|
|---|
| 103 | body,
|
|---|
| 104 | });
|
|---|
| 105 | return r.status;
|
|---|
| 106 | }
|
|---|
| 107 |
|
|---|
| 108 | async function inboxVan(actorUri) {
|
|---|
| 109 | const r = await fetch(actorUri, { headers: { Accept: 'application/activity+json' } });
|
|---|
| 110 | if (!r.ok) throw new Error(`actor ${actorUri}: ${r.status}`);
|
|---|
| 111 | const doc = await r.json();
|
|---|
| 112 | return doc.inbox || (doc.endpoints && doc.endpoints.sharedInbox);
|
|---|
| 113 | }
|
|---|
| 114 |
|
|---|
| 115 | // ── De handshake (FEP-633c 3.1) ──────────────────────────────────────────
|
|---|
| 116 | //
|
|---|
| 117 | // De kandidaat biedt aan met een Offer van Relationship{subject: ward, object:
|
|---|
| 118 | // kandidaat}, gericht aan de ward en aan de bestaande guardians. Elke partij
|
|---|
| 119 | // accepteert. Wij zijn de WARD: wij accepteren, gericht aan alle anderen, en de
|
|---|
| 120 | // kandidaat sluit af met de laatste Accept -- dat is de commit.
|
|---|
| 121 | //
|
|---|
| 122 | // AUTOMATISCH JA, en alleen omdat dit een testkudde is. Bij een echt kind is
|
|---|
| 123 | // juist dit het moment waarop iemand moet nadenken; dat staat er hier expliciet
|
|---|
| 124 | // bij zodat deze code nooit ergens anders terechtkomt.
|
|---|
| 125 | //
|
|---|
| 126 | // HET OFFER IS AL DE TOESTEMMING VAN DE KANDIDAAT, en daar zat mijn fout. Ik
|
|---|
| 127 | // wachtte op een afsluitende Accept van de kandidaat, en die komt bij een VRIJE
|
|---|
| 128 | // ward nooit -- terecht. Klonkt zegt het met zoveel woorden in handshake.js:
|
|---|
| 129 | // "The Offer carries the candidate's agreement -- a free ward then commits on
|
|---|
| 130 | // its own accept." Dev deed dus precies wat het hoorde te doen; mijn daemon
|
|---|
| 131 | // bleef wachten op iets dat het protocol niet stuurt.
|
|---|
| 132 | //
|
|---|
| 133 | // De afsluitende Accept van de kandidaat bestaat WEL, maar in het meerpartijen-
|
|---|
| 134 | // geval: heeft het kind al guardians, dan moeten die eerst ja zeggen en sluit de
|
|---|
| 135 | // kandidaat af. Vandaar de telling hieronder in plaats van "commit bij mijn
|
|---|
| 136 | // eigen ja" -- dat laatste zou werken voor de eerste guardian en stilletjes fout
|
|---|
| 137 | // zijn voor de tweede.
|
|---|
| 138 |
|
|---|
| 139 | /**
|
|---|
| 140 | * Alles wat er gebeurt, en meteen naar schijf.
|
|---|
| 141 | *
|
|---|
| 142 | * Stond eerst los: een push in het geheugen, en wegschrijven alleen in de takken
|
|---|
| 143 | * die toch al iets bewaarden. Gevolg: /log en state.json spraken elkaar tegen --
|
|---|
| 144 | * de een had een Follow die de ander niet kende. Bij een testkudde die je gebruikt
|
|---|
| 145 | * om een fout te vinden is dat het ergste wat je kunt hebben: dan zoek je in
|
|---|
| 146 | * bewijs dat zelf niet klopt.
|
|---|
| 147 | */
|
|---|
| 148 | function noteer(w, regel) {
|
|---|
| 149 | w.log.push({ at: new Date().toISOString(), ...regel });
|
|---|
| 150 | if (w.log.length > 80) w.log = w.log.slice(-80);
|
|---|
| 151 | bewaarStaat(staat);
|
|---|
| 152 | }
|
|---|
| 153 |
|
|---|
| 154 | /**
|
|---|
| 155 | * Een ja bijschrijven, en committen zodra iedereen ja heeft gezegd.
|
|---|
| 156 | *
|
|---|
| 157 | * Dezelfde regel als readyToCommit in Klonkt: de ward, de kandidaat, en -- als
|
|---|
| 158 | * het kind er al had -- minstens een van de bestaande guardians.
|
|---|
| 159 | */
|
|---|
| 160 | function telAccept(w, offerId, wie) {
|
|---|
| 161 | const off = w.offers[offerId];
|
|---|
| 162 | if (!off || off.commit) return 'onbekend offer';
|
|---|
| 163 | if (wie && !off.accepts.includes(wie)) off.accepts.push(wie);
|
|---|
| 164 | const iedereen = off.partijen.every((p) => off.accepts.includes(p));
|
|---|
| 165 | if (!iedereen) { noteer(w, { wacht_op: off.partijen.filter((p) => !off.accepts.includes(p)) }); return 'nog niet compleet'; }
|
|---|
| 166 | off.commit = new Date().toISOString();
|
|---|
| 167 | if (!w.guardians.includes(off.kandidaat)) w.guardians.push(off.kandidaat);
|
|---|
| 168 | noteer(w, { commit: off.kandidaat });
|
|---|
| 169 | return 'guardian erbij';
|
|---|
| 170 | }
|
|---|
| 171 |
|
|---|
| 172 | async function verwerkInbox(w, activity) {
|
|---|
| 173 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| 174 | const actor = typeof activity.actor === 'string' ? activity.actor : (activity.actor && activity.actor.id);
|
|---|
| 175 | noteer(w, { type, actor });
|
|---|
| 176 |
|
|---|
| 177 | const o = activity.object;
|
|---|
| 178 | const relType = o && (Array.isArray(o.type) ? o.type[0] : o.type);
|
|---|
| 179 |
|
|---|
| 180 | if (type === 'Offer' && relType === 'Relationship') {
|
|---|
| 181 | const subject = typeof o.subject === 'string' ? o.subject : (o.subject && o.subject.id);
|
|---|
| 182 | if (subject !== uriVan(w.naam)) return 'niet voor mij';
|
|---|
| 183 | const kandidaat = typeof o.object === 'string' ? o.object : (o.object && o.object.id);
|
|---|
| 184 | // De partijen: de kandidaat, ikzelf, en mijn bestaande guardians (die staan
|
|---|
| 185 | // in `to`). De kandidaat telt meteen als ja -- zijn Offer IS zijn instemming.
|
|---|
| 186 | const partijen = [...new Set([
|
|---|
| 187 | kandidaat,
|
|---|
| 188 | uriVan(w.naam),
|
|---|
| 189 | ...(Array.isArray(activity.to) ? activity.to : []),
|
|---|
| 190 | ])].filter(Boolean);
|
|---|
| 191 | w.offers[activity.id] = { kandidaat, at: Date.now(), partijen, accepts: [kandidaat] };
|
|---|
| 192 | bewaarStaat(staat);
|
|---|
| 193 | // Accepteren, gericht aan iedereen die in `to` stond plus de kandidaat, zodat
|
|---|
| 194 | // elke kopie van de telling dezelfde kant op loopt.
|
|---|
| 195 | const aan = [...new Set([...(Array.isArray(activity.to) ? activity.to : []), kandidaat])]
|
|---|
| 196 | .filter((x) => x && x !== uriVan(w.naam));
|
|---|
| 197 | for (const doel of aan) {
|
|---|
| 198 | try {
|
|---|
| 199 | const inbox = await inboxVan(doel);
|
|---|
| 200 | const code = await bezorg(w, inbox, {
|
|---|
| 201 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://shaer.klonkt.com/ns#' }],
|
|---|
| 202 | id: `${uriVan(w.naam)}/accepts/${crypto.randomUUID()}`,
|
|---|
| 203 | // `object` is de OFFER-ID, niet het hele Offer. Zo stuurt Klonkt hem
|
|---|
| 204 | // ook (fanout in handshake.js), en een testkudde die er net anders
|
|---|
| 205 | // uitziet dan een echte implementatie laat je een verschil onderzoeken
|
|---|
| 206 | // dat je zelf gemaakt hebt.
|
|---|
| 207 | type: 'Accept', actor: uriVan(w.naam), to: aan, object: activity.id,
|
|---|
| 208 | });
|
|---|
| 209 | // De statuscode erbij. "Hij stuurde een Accept" is een aanname zolang je
|
|---|
| 210 | // niet weet wat de andere kant ervan vond.
|
|---|
| 211 | noteer(w, { verstuurd: 'Accept', naar: doel, code });
|
|---|
| 212 | } catch (e) { noteer(w, { fout: String(e.message), naar: doel }); }
|
|---|
| 213 | }
|
|---|
| 214 | telAccept(w, activity.id, uriVan(w.naam));
|
|---|
| 215 | return 'geaccepteerd';
|
|---|
| 216 | }
|
|---|
| 217 |
|
|---|
| 218 | if (type === 'Accept') {
|
|---|
| 219 | // Een ja van een mede-partij (een bestaande guardian, of de kandidaat die in
|
|---|
| 220 | // het meerpartijengeval afsluit). `object` is de offer-id.
|
|---|
| 221 | const offerId = typeof o === 'string' ? o : (o && o.id);
|
|---|
| 222 | if (!offerId || !w.offers[offerId]) return 'onbekend offer';
|
|---|
| 223 | return telAccept(w, offerId, actor);
|
|---|
| 224 | }
|
|---|
| 225 |
|
|---|
| 226 | if (type === 'Undo') {
|
|---|
| 227 | const rel = o && (Array.isArray(o.type) ? o.type[0] : o.type) === 'Relationship' ? o : null;
|
|---|
| 228 | const wie = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
|
|---|
| 229 | if (wie) {
|
|---|
| 230 | w.guardians = w.guardians.filter((g) => g !== wie);
|
|---|
| 231 | bewaarStaat(staat);
|
|---|
| 232 | return 'guardian eraf';
|
|---|
| 233 | }
|
|---|
| 234 | }
|
|---|
| 235 | return 'genegeerd';
|
|---|
| 236 | }
|
|---|
| 237 |
|
|---|
| 238 | // ── Documenten ───────────────────────────────────────────────────────────
|
|---|
| 239 |
|
|---|
| 240 | function actorDoc(w) {
|
|---|
| 241 | const id = uriVan(w.naam);
|
|---|
| 242 | const doc = {
|
|---|
| 243 | '@context': [
|
|---|
| 244 | 'https://www.w3.org/ns/activitystreams',
|
|---|
| 245 | 'https://w3id.org/security/v1',
|
|---|
| 246 | { shaer: 'https://shaer.klonkt.com/ns#' },
|
|---|
| 247 | ],
|
|---|
| 248 | id,
|
|---|
| 249 | type: 'Person',
|
|---|
| 250 | preferredUsername: w.naam,
|
|---|
| 251 | name: `Ward ${w.naam}`,
|
|---|
| 252 | inbox: `${id}/inbox`,
|
|---|
| 253 | outbox: `${id}/outbox`,
|
|---|
| 254 | followers: `${id}/followers`,
|
|---|
| 255 | following: `${id}/following`,
|
|---|
| 256 | publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: w.publicKey },
|
|---|
| 257 | };
|
|---|
| 258 | // 2.1: alleen aanwezig als er echt guardians zijn. Een lege lijst zou zeggen
|
|---|
| 259 | // "dit kind heeft er geen", en dat is iets anders dan "nog niet gecommit".
|
|---|
| 260 | if (w.guardians.length) doc['shaer:guardians'] = w.guardians;
|
|---|
| 261 | return doc;
|
|---|
| 262 | }
|
|---|
| 263 |
|
|---|
| 264 | function stuur(res, code, body, type = 'application/activity+json') {
|
|---|
| 265 | const s = typeof body === 'string' ? body : JSON.stringify(body);
|
|---|
| 266 | res.writeHead(code, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(s) });
|
|---|
| 267 | res.end(s);
|
|---|
| 268 | }
|
|---|
| 269 |
|
|---|
| 270 | // ── Server ───────────────────────────────────────────────────────────────
|
|---|
| 271 |
|
|---|
| 272 | const server = http.createServer(async (req, res) => {
|
|---|
| 273 | const u = new URL(req.url, BASE);
|
|---|
| 274 | const p = u.pathname;
|
|---|
| 275 |
|
|---|
| 276 | if (p === '/.well-known/webfinger') {
|
|---|
| 277 | const m = String(u.searchParams.get('resource') || '').match(/^acct:(w\d{3})@/);
|
|---|
| 278 | if (!m) return stuur(res, 404, { error: 'not found' }, 'application/json');
|
|---|
| 279 | const id = uriVan(m[1]);
|
|---|
| 280 | return stuur(res, 200, {
|
|---|
| 281 | subject: `acct:${m[1]}@${HOST}`,
|
|---|
| 282 | links: [{ rel: 'self', type: 'application/activity+json', href: id }],
|
|---|
| 283 | }, 'application/jrd+json');
|
|---|
| 284 | }
|
|---|
| 285 |
|
|---|
| 286 | if (p === '/status') {
|
|---|
| 287 | const wards = Object.values(staat.wards);
|
|---|
| 288 | return stuur(res, 200, {
|
|---|
| 289 | wards: wards.length,
|
|---|
| 290 | metGuardian: wards.filter((w) => w.guardians.length).length,
|
|---|
| 291 | voorbeeld: wards[0] ? { naam: wards[0].naam, guardians: wards[0].guardians } : null,
|
|---|
| 292 | }, 'application/json');
|
|---|
| 293 | }
|
|---|
| 294 |
|
|---|
| 295 | const mInbox = p.match(/^\/u\/(w\d{3})\/inbox$/);
|
|---|
| 296 | if (mInbox && req.method === 'POST') {
|
|---|
| 297 | const w = staat.wards[mInbox[1]];
|
|---|
| 298 | if (!w) return stuur(res, 404, { error: 'no such ward' });
|
|---|
| 299 | let body = '';
|
|---|
| 300 | for await (const c of req) body += c;
|
|---|
| 301 | let uit = 'onleesbaar';
|
|---|
| 302 | try { uit = await verwerkInbox(w, JSON.parse(body)); }
|
|---|
| 303 | catch (e) { noteer(w, { fout: String(e.message) }); }
|
|---|
| 304 | // 202 hoe dan ook: een inbox die 4xx geeft op iets dat hij niet kent laat de
|
|---|
| 305 | // afzender eindeloos opnieuw proberen.
|
|---|
| 306 | return stuur(res, 202, { ok: true, uit });
|
|---|
| 307 | }
|
|---|
| 308 |
|
|---|
| 309 | const mOutbox = p.match(/^\/u\/(w\d{3})\/outbox$/);
|
|---|
| 310 | if (mOutbox) {
|
|---|
| 311 | return stuur(res, 200, {
|
|---|
| 312 | '@context': 'https://www.w3.org/ns/activitystreams',
|
|---|
| 313 | id: `${uriVan(mOutbox[1])}/outbox`, type: 'OrderedCollection', totalItems: 0, orderedItems: [],
|
|---|
| 314 | });
|
|---|
| 315 | }
|
|---|
| 316 |
|
|---|
| 317 | const mActor = p.match(/^\/u\/(w\d{3})\/?$/);
|
|---|
| 318 | if (mActor) {
|
|---|
| 319 | const w = staat.wards[mActor[1]];
|
|---|
| 320 | if (!w) return stuur(res, 404, { error: 'no such ward' });
|
|---|
| 321 | return stuur(res, 200, actorDoc(w));
|
|---|
| 322 | }
|
|---|
| 323 |
|
|---|
| 324 | const mLog = p.match(/^\/u\/(w\d{3})\/log$/);
|
|---|
| 325 | if (mLog) {
|
|---|
| 326 | const w = staat.wards[mLog[1]];
|
|---|
| 327 | return w ? stuur(res, 200, w.log, 'application/json') : stuur(res, 404, {}, 'application/json');
|
|---|
| 328 | }
|
|---|
| 329 |
|
|---|
| 330 | stuur(res, 404, { error: 'not found' }, 'application/json');
|
|---|
| 331 | });
|
|---|
| 332 |
|
|---|
| 333 | // De sleutels vooraf maken, zodat de eerste binnenkomende fetch niet op
|
|---|
| 334 | // keygen hoeft te wachten -- 100 keer 2048 bits duurt even.
|
|---|
| 335 | for (let i = 1; i <= N; i++) ward(i);
|
|---|
| 336 | bewaarStaat(staat);
|
|---|
| 337 |
|
|---|
| 338 | server.listen(PORT, '::1', () => {
|
|---|
| 339 | console.log(`[farm] ${N} wards op ${BASE} (luistert op [::1]:${PORT})`);
|
|---|
| 340 | });
|
|---|