| 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 | const BASE = process.env.BASE || 'https://wards.klonkt.com';
|
|---|
| 36 | const PORT = Number(process.env.PORT || 3060);
|
|---|
| 37 | const N = Number(process.env.WARDS || 100);
|
|---|
| 38 | const HOST = new URL(BASE).host;
|
|---|
| 39 |
|
|---|
| 40 | // ── Staat ────────────────────────────────────────────────────────────────
|
|---|
| 41 | // Een plat bestand. Dit is een testkudde, geen instance: als de staat weg is
|
|---|
| 42 | // begin je opnieuw, en dat hoort ook zo -- anders sluipt er toestand in waarvan
|
|---|
| 43 | // niemand meer weet waar hij vandaan kwam.
|
|---|
| 44 |
|
|---|
| 45 | function laadStaat() {
|
|---|
| 46 | try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { wards: {} }; }
|
|---|
| 47 | }
|
|---|
| 48 | function bewaarStaat(s) {
|
|---|
| 49 | fs.writeFileSync(STATE, JSON.stringify(s, null, 2));
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | const staat = laadStaat();
|
|---|
| 53 |
|
|---|
| 54 | function ward(n) {
|
|---|
| 55 | const naam = `w${String(n).padStart(3, '0')}`;
|
|---|
| 56 | if (!staat.wards[naam]) {
|
|---|
| 57 | const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
|---|
| 58 | modulusLength: 2048,
|
|---|
| 59 | publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|---|
| 60 | privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|---|
| 61 | });
|
|---|
| 62 | staat.wards[naam] = { naam, publicKey, privateKey, guardians: [], offers: {}, log: [] };
|
|---|
| 63 | bewaarStaat(staat);
|
|---|
| 64 | }
|
|---|
| 65 | return staat.wards[naam];
|
|---|
| 66 | }
|
|---|
| 67 |
|
|---|
| 68 | const uriVan = (naam) => `${BASE}/u/${naam}`;
|
|---|
| 69 | const naamUit = (uri) => {
|
|---|
| 70 | const m = String(uri || '').match(/\/u\/(w\d{3})\/?$/);
|
|---|
| 71 | return m ? m[1] : null;
|
|---|
| 72 | };
|
|---|
| 73 |
|
|---|
| 74 | // ── Ondertekenen ─────────────────────────────────────────────────────────
|
|---|
| 75 | // (request-target) host date digest -- wat Mastodon stuurt en wat Klonkt
|
|---|
| 76 | // verwacht. Digest is verplicht zodra er een body is: zonder ondertekende
|
|---|
| 77 | // digest bewijst een handtekening niets over de inhoud.
|
|---|
| 78 |
|
|---|
| 79 | async function bezorg(w, inbox, activity) {
|
|---|
| 80 | const body = JSON.stringify(activity);
|
|---|
| 81 | const u = new URL(inbox);
|
|---|
| 82 | const date = new Date().toUTCString();
|
|---|
| 83 | const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
|
|---|
| 84 | const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
|
|---|
| 85 | const signature = crypto.sign('sha256', Buffer.from(signingString), w.privateKey).toString('base64');
|
|---|
| 86 | const sig = `keyId="${uriVan(w.naam)}#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
|
|---|
| 87 | const r = await fetch(inbox, {
|
|---|
| 88 | method: 'POST',
|
|---|
| 89 | headers: {
|
|---|
| 90 | 'Content-Type': 'application/activity+json',
|
|---|
| 91 | Accept: 'application/activity+json',
|
|---|
| 92 | Date: date, Digest: digest, Signature: sig,
|
|---|
| 93 | },
|
|---|
| 94 | body,
|
|---|
| 95 | });
|
|---|
| 96 | return r.status;
|
|---|
| 97 | }
|
|---|
| 98 |
|
|---|
| 99 | async function inboxVan(actorUri) {
|
|---|
| 100 | const r = await fetch(actorUri, { headers: { Accept: 'application/activity+json' } });
|
|---|
| 101 | if (!r.ok) throw new Error(`actor ${actorUri}: ${r.status}`);
|
|---|
| 102 | const doc = await r.json();
|
|---|
| 103 | return doc.inbox || (doc.endpoints && doc.endpoints.sharedInbox);
|
|---|
| 104 | }
|
|---|
| 105 |
|
|---|
| 106 | // ── De handshake (FEP-633c 3.1) ──────────────────────────────────────────
|
|---|
| 107 | //
|
|---|
| 108 | // De kandidaat biedt aan met een Offer van Relationship{subject: ward, object:
|
|---|
| 109 | // kandidaat}, gericht aan de ward en aan de bestaande guardians. Elke partij
|
|---|
| 110 | // accepteert. Wij zijn de WARD: wij accepteren, gericht aan alle anderen, en de
|
|---|
| 111 | // kandidaat sluit af met de laatste Accept -- dat is de commit.
|
|---|
| 112 | //
|
|---|
| 113 | // AUTOMATISCH JA, en alleen omdat dit een testkudde is. Bij een echt kind is
|
|---|
| 114 | // juist dit het moment waarop iemand moet nadenken; dat staat er hier expliciet
|
|---|
| 115 | // bij zodat deze code nooit ergens anders terechtkomt.
|
|---|
| 116 |
|
|---|
| 117 | async function verwerkInbox(w, activity) {
|
|---|
| 118 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| 119 | const actor = typeof activity.actor === 'string' ? activity.actor : (activity.actor && activity.actor.id);
|
|---|
| 120 | w.log.push({ at: new Date().toISOString(), type, actor });
|
|---|
| 121 | if (w.log.length > 50) w.log = w.log.slice(-50);
|
|---|
| 122 |
|
|---|
| 123 | const o = activity.object;
|
|---|
| 124 | const relType = o && (Array.isArray(o.type) ? o.type[0] : o.type);
|
|---|
| 125 |
|
|---|
| 126 | if (type === 'Offer' && relType === 'Relationship') {
|
|---|
| 127 | const subject = typeof o.subject === 'string' ? o.subject : (o.subject && o.subject.id);
|
|---|
| 128 | if (subject !== uriVan(w.naam)) return 'niet voor mij';
|
|---|
| 129 | const kandidaat = typeof o.object === 'string' ? o.object : (o.object && o.object.id);
|
|---|
| 130 | w.offers[activity.id] = { kandidaat, at: Date.now() };
|
|---|
| 131 | bewaarStaat(staat);
|
|---|
| 132 | // Accepteren, gericht aan iedereen die in `to` stond plus de kandidaat, zodat
|
|---|
| 133 | // elke kopie van de telling dezelfde kant op loopt.
|
|---|
| 134 | const aan = [...new Set([...(Array.isArray(activity.to) ? activity.to : []), kandidaat])]
|
|---|
| 135 | .filter((x) => x && x !== uriVan(w.naam));
|
|---|
| 136 | for (const doel of aan) {
|
|---|
| 137 | try {
|
|---|
| 138 | const inbox = await inboxVan(doel);
|
|---|
| 139 | await bezorg(w, inbox, {
|
|---|
| 140 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://shaer.klonkt.com/ns#' }],
|
|---|
| 141 | id: `${uriVan(w.naam)}/accepts/${crypto.randomUUID()}`,
|
|---|
| 142 | type: 'Accept', actor: uriVan(w.naam), to: aan, object: activity,
|
|---|
| 143 | });
|
|---|
| 144 | } catch (e) { w.log.push({ at: new Date().toISOString(), fout: String(e.message) }); }
|
|---|
| 145 | }
|
|---|
| 146 | bewaarStaat(staat);
|
|---|
| 147 | return 'geaccepteerd';
|
|---|
| 148 | }
|
|---|
| 149 |
|
|---|
| 150 | if (type === 'Accept') {
|
|---|
| 151 | // De laatste Accept van de kandidaat is de commit (3.1.3). Wij houden hem
|
|---|
| 152 | // gewoon bij: wie ons bewaakt is het enige dat wij hoeven te weten.
|
|---|
| 153 | const binnenste = o && o.object;
|
|---|
| 154 | const rel = binnenste && (Array.isArray(binnenste.type) ? binnenste.type[0] : binnenste.type) === 'Relationship'
|
|---|
| 155 | ? binnenste : (relType === 'Relationship' ? o : null);
|
|---|
| 156 | const kandidaat = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
|
|---|
| 157 | const wie = kandidaat || actor;
|
|---|
| 158 | if (wie && !w.guardians.includes(wie)) {
|
|---|
| 159 | w.guardians.push(wie);
|
|---|
| 160 | bewaarStaat(staat);
|
|---|
| 161 | return 'guardian erbij';
|
|---|
| 162 | }
|
|---|
| 163 | return 'al bekend';
|
|---|
| 164 | }
|
|---|
| 165 |
|
|---|
| 166 | if (type === 'Undo') {
|
|---|
| 167 | const rel = o && (Array.isArray(o.type) ? o.type[0] : o.type) === 'Relationship' ? o : null;
|
|---|
| 168 | const wie = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
|
|---|
| 169 | if (wie) {
|
|---|
| 170 | w.guardians = w.guardians.filter((g) => g !== wie);
|
|---|
| 171 | bewaarStaat(staat);
|
|---|
| 172 | return 'guardian eraf';
|
|---|
| 173 | }
|
|---|
| 174 | }
|
|---|
| 175 | return 'genegeerd';
|
|---|
| 176 | }
|
|---|
| 177 |
|
|---|
| 178 | // ── Documenten ───────────────────────────────────────────────────────────
|
|---|
| 179 |
|
|---|
| 180 | function actorDoc(w) {
|
|---|
| 181 | const id = uriVan(w.naam);
|
|---|
| 182 | const doc = {
|
|---|
| 183 | '@context': [
|
|---|
| 184 | 'https://www.w3.org/ns/activitystreams',
|
|---|
| 185 | 'https://w3id.org/security/v1',
|
|---|
| 186 | { shaer: 'https://shaer.klonkt.com/ns#' },
|
|---|
| 187 | ],
|
|---|
| 188 | id,
|
|---|
| 189 | type: 'Person',
|
|---|
| 190 | preferredUsername: w.naam,
|
|---|
| 191 | name: `Ward ${w.naam}`,
|
|---|
| 192 | inbox: `${id}/inbox`,
|
|---|
| 193 | outbox: `${id}/outbox`,
|
|---|
| 194 | followers: `${id}/followers`,
|
|---|
| 195 | following: `${id}/following`,
|
|---|
| 196 | publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: w.publicKey },
|
|---|
| 197 | };
|
|---|
| 198 | // 2.1: alleen aanwezig als er echt guardians zijn. Een lege lijst zou zeggen
|
|---|
| 199 | // "dit kind heeft er geen", en dat is iets anders dan "nog niet gecommit".
|
|---|
| 200 | if (w.guardians.length) doc['shaer:guardians'] = w.guardians;
|
|---|
| 201 | return doc;
|
|---|
| 202 | }
|
|---|
| 203 |
|
|---|
| 204 | function stuur(res, code, body, type = 'application/activity+json') {
|
|---|
| 205 | const s = typeof body === 'string' ? body : JSON.stringify(body);
|
|---|
| 206 | res.writeHead(code, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(s) });
|
|---|
| 207 | res.end(s);
|
|---|
| 208 | }
|
|---|
| 209 |
|
|---|
| 210 | // ── Server ───────────────────────────────────────────────────────────────
|
|---|
| 211 |
|
|---|
| 212 | const server = http.createServer(async (req, res) => {
|
|---|
| 213 | const u = new URL(req.url, BASE);
|
|---|
| 214 | const p = u.pathname;
|
|---|
| 215 |
|
|---|
| 216 | if (p === '/.well-known/webfinger') {
|
|---|
| 217 | const m = String(u.searchParams.get('resource') || '').match(/^acct:(w\d{3})@/);
|
|---|
| 218 | if (!m) return stuur(res, 404, { error: 'not found' }, 'application/json');
|
|---|
| 219 | const id = uriVan(m[1]);
|
|---|
| 220 | return stuur(res, 200, {
|
|---|
| 221 | subject: `acct:${m[1]}@${HOST}`,
|
|---|
| 222 | links: [{ rel: 'self', type: 'application/activity+json', href: id }],
|
|---|
| 223 | }, 'application/jrd+json');
|
|---|
| 224 | }
|
|---|
| 225 |
|
|---|
| 226 | if (p === '/status') {
|
|---|
| 227 | const wards = Object.values(staat.wards);
|
|---|
| 228 | return stuur(res, 200, {
|
|---|
| 229 | wards: wards.length,
|
|---|
| 230 | metGuardian: wards.filter((w) => w.guardians.length).length,
|
|---|
| 231 | voorbeeld: wards[0] ? { naam: wards[0].naam, guardians: wards[0].guardians } : null,
|
|---|
| 232 | }, 'application/json');
|
|---|
| 233 | }
|
|---|
| 234 |
|
|---|
| 235 | const mInbox = p.match(/^\/u\/(w\d{3})\/inbox$/);
|
|---|
| 236 | if (mInbox && req.method === 'POST') {
|
|---|
| 237 | const w = staat.wards[mInbox[1]];
|
|---|
| 238 | if (!w) return stuur(res, 404, { error: 'no such ward' });
|
|---|
| 239 | let body = '';
|
|---|
| 240 | for await (const c of req) body += c;
|
|---|
| 241 | let uit = 'onleesbaar';
|
|---|
| 242 | try { uit = await verwerkInbox(w, JSON.parse(body)); }
|
|---|
| 243 | catch (e) { w.log.push({ at: new Date().toISOString(), fout: String(e.message) }); }
|
|---|
| 244 | // 202 hoe dan ook: een inbox die 4xx geeft op iets dat hij niet kent laat de
|
|---|
| 245 | // afzender eindeloos opnieuw proberen.
|
|---|
| 246 | return stuur(res, 202, { ok: true, uit });
|
|---|
| 247 | }
|
|---|
| 248 |
|
|---|
| 249 | const mOutbox = p.match(/^\/u\/(w\d{3})\/outbox$/);
|
|---|
| 250 | if (mOutbox) {
|
|---|
| 251 | return stuur(res, 200, {
|
|---|
| 252 | '@context': 'https://www.w3.org/ns/activitystreams',
|
|---|
| 253 | id: `${uriVan(mOutbox[1])}/outbox`, type: 'OrderedCollection', totalItems: 0, orderedItems: [],
|
|---|
| 254 | });
|
|---|
| 255 | }
|
|---|
| 256 |
|
|---|
| 257 | const mActor = p.match(/^\/u\/(w\d{3})\/?$/);
|
|---|
| 258 | if (mActor) {
|
|---|
| 259 | const w = staat.wards[mActor[1]];
|
|---|
| 260 | if (!w) return stuur(res, 404, { error: 'no such ward' });
|
|---|
| 261 | return stuur(res, 200, actorDoc(w));
|
|---|
| 262 | }
|
|---|
| 263 |
|
|---|
| 264 | const mLog = p.match(/^\/u\/(w\d{3})\/log$/);
|
|---|
| 265 | if (mLog) {
|
|---|
| 266 | const w = staat.wards[mLog[1]];
|
|---|
| 267 | return w ? stuur(res, 200, w.log, 'application/json') : stuur(res, 404, {}, 'application/json');
|
|---|
| 268 | }
|
|---|
| 269 |
|
|---|
| 270 | stuur(res, 404, { error: 'not found' }, 'application/json');
|
|---|
| 271 | });
|
|---|
| 272 |
|
|---|
| 273 | // De sleutels vooraf maken, zodat de eerste binnenkomende fetch niet op
|
|---|
| 274 | // keygen hoeft te wachten -- 100 keer 2048 bits duurt even.
|
|---|
| 275 | for (let i = 1; i <= N; i++) ward(i);
|
|---|
| 276 | bewaarStaat(staat);
|
|---|
| 277 |
|
|---|
| 278 | server.listen(PORT, '127.0.0.1', () => {
|
|---|
| 279 | console.log(`[farm] ${N} wards op ${BASE} (luistert op 127.0.0.1:${PORT})`);
|
|---|
| 280 | });
|
|---|