| 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 | async function verwerkInbox(w, activity) {
|
|---|
| 127 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| 128 | const actor = typeof activity.actor === 'string' ? activity.actor : (activity.actor && activity.actor.id);
|
|---|
| 129 | w.log.push({ at: new Date().toISOString(), type, actor });
|
|---|
| 130 | if (w.log.length > 50) w.log = w.log.slice(-50);
|
|---|
| 131 |
|
|---|
| 132 | const o = activity.object;
|
|---|
| 133 | const relType = o && (Array.isArray(o.type) ? o.type[0] : o.type);
|
|---|
| 134 |
|
|---|
| 135 | if (type === 'Offer' && relType === 'Relationship') {
|
|---|
| 136 | const subject = typeof o.subject === 'string' ? o.subject : (o.subject && o.subject.id);
|
|---|
| 137 | if (subject !== uriVan(w.naam)) return 'niet voor mij';
|
|---|
| 138 | const kandidaat = typeof o.object === 'string' ? o.object : (o.object && o.object.id);
|
|---|
| 139 | w.offers[activity.id] = { kandidaat, at: Date.now() };
|
|---|
| 140 | bewaarStaat(staat);
|
|---|
| 141 | // Accepteren, gericht aan iedereen die in `to` stond plus de kandidaat, zodat
|
|---|
| 142 | // elke kopie van de telling dezelfde kant op loopt.
|
|---|
| 143 | const aan = [...new Set([...(Array.isArray(activity.to) ? activity.to : []), kandidaat])]
|
|---|
| 144 | .filter((x) => x && x !== uriVan(w.naam));
|
|---|
| 145 | for (const doel of aan) {
|
|---|
| 146 | try {
|
|---|
| 147 | const inbox = await inboxVan(doel);
|
|---|
| 148 | await bezorg(w, inbox, {
|
|---|
| 149 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://shaer.klonkt.com/ns#' }],
|
|---|
| 150 | id: `${uriVan(w.naam)}/accepts/${crypto.randomUUID()}`,
|
|---|
| 151 | type: 'Accept', actor: uriVan(w.naam), to: aan, object: activity,
|
|---|
| 152 | });
|
|---|
| 153 | } catch (e) { w.log.push({ at: new Date().toISOString(), fout: String(e.message) }); }
|
|---|
| 154 | }
|
|---|
| 155 | bewaarStaat(staat);
|
|---|
| 156 | return 'geaccepteerd';
|
|---|
| 157 | }
|
|---|
| 158 |
|
|---|
| 159 | if (type === 'Accept') {
|
|---|
| 160 | // De laatste Accept van de kandidaat is de commit (3.1.3). Wij houden hem
|
|---|
| 161 | // gewoon bij: wie ons bewaakt is het enige dat wij hoeven te weten.
|
|---|
| 162 | const binnenste = o && o.object;
|
|---|
| 163 | const rel = binnenste && (Array.isArray(binnenste.type) ? binnenste.type[0] : binnenste.type) === 'Relationship'
|
|---|
| 164 | ? binnenste : (relType === 'Relationship' ? o : null);
|
|---|
| 165 | const kandidaat = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
|
|---|
| 166 | const wie = kandidaat || actor;
|
|---|
| 167 | if (wie && !w.guardians.includes(wie)) {
|
|---|
| 168 | w.guardians.push(wie);
|
|---|
| 169 | bewaarStaat(staat);
|
|---|
| 170 | return 'guardian erbij';
|
|---|
| 171 | }
|
|---|
| 172 | return 'al bekend';
|
|---|
| 173 | }
|
|---|
| 174 |
|
|---|
| 175 | if (type === 'Undo') {
|
|---|
| 176 | const rel = o && (Array.isArray(o.type) ? o.type[0] : o.type) === 'Relationship' ? o : null;
|
|---|
| 177 | const wie = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
|
|---|
| 178 | if (wie) {
|
|---|
| 179 | w.guardians = w.guardians.filter((g) => g !== wie);
|
|---|
| 180 | bewaarStaat(staat);
|
|---|
| 181 | return 'guardian eraf';
|
|---|
| 182 | }
|
|---|
| 183 | }
|
|---|
| 184 | return 'genegeerd';
|
|---|
| 185 | }
|
|---|
| 186 |
|
|---|
| 187 | // ── Documenten ───────────────────────────────────────────────────────────
|
|---|
| 188 |
|
|---|
| 189 | function actorDoc(w) {
|
|---|
| 190 | const id = uriVan(w.naam);
|
|---|
| 191 | const doc = {
|
|---|
| 192 | '@context': [
|
|---|
| 193 | 'https://www.w3.org/ns/activitystreams',
|
|---|
| 194 | 'https://w3id.org/security/v1',
|
|---|
| 195 | { shaer: 'https://shaer.klonkt.com/ns#' },
|
|---|
| 196 | ],
|
|---|
| 197 | id,
|
|---|
| 198 | type: 'Person',
|
|---|
| 199 | preferredUsername: w.naam,
|
|---|
| 200 | name: `Ward ${w.naam}`,
|
|---|
| 201 | inbox: `${id}/inbox`,
|
|---|
| 202 | outbox: `${id}/outbox`,
|
|---|
| 203 | followers: `${id}/followers`,
|
|---|
| 204 | following: `${id}/following`,
|
|---|
| 205 | publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: w.publicKey },
|
|---|
| 206 | };
|
|---|
| 207 | // 2.1: alleen aanwezig als er echt guardians zijn. Een lege lijst zou zeggen
|
|---|
| 208 | // "dit kind heeft er geen", en dat is iets anders dan "nog niet gecommit".
|
|---|
| 209 | if (w.guardians.length) doc['shaer:guardians'] = w.guardians;
|
|---|
| 210 | return doc;
|
|---|
| 211 | }
|
|---|
| 212 |
|
|---|
| 213 | function stuur(res, code, body, type = 'application/activity+json') {
|
|---|
| 214 | const s = typeof body === 'string' ? body : JSON.stringify(body);
|
|---|
| 215 | res.writeHead(code, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(s) });
|
|---|
| 216 | res.end(s);
|
|---|
| 217 | }
|
|---|
| 218 |
|
|---|
| 219 | // ── Server ───────────────────────────────────────────────────────────────
|
|---|
| 220 |
|
|---|
| 221 | const server = http.createServer(async (req, res) => {
|
|---|
| 222 | const u = new URL(req.url, BASE);
|
|---|
| 223 | const p = u.pathname;
|
|---|
| 224 |
|
|---|
| 225 | if (p === '/.well-known/webfinger') {
|
|---|
| 226 | const m = String(u.searchParams.get('resource') || '').match(/^acct:(w\d{3})@/);
|
|---|
| 227 | if (!m) return stuur(res, 404, { error: 'not found' }, 'application/json');
|
|---|
| 228 | const id = uriVan(m[1]);
|
|---|
| 229 | return stuur(res, 200, {
|
|---|
| 230 | subject: `acct:${m[1]}@${HOST}`,
|
|---|
| 231 | links: [{ rel: 'self', type: 'application/activity+json', href: id }],
|
|---|
| 232 | }, 'application/jrd+json');
|
|---|
| 233 | }
|
|---|
| 234 |
|
|---|
| 235 | if (p === '/status') {
|
|---|
| 236 | const wards = Object.values(staat.wards);
|
|---|
| 237 | return stuur(res, 200, {
|
|---|
| 238 | wards: wards.length,
|
|---|
| 239 | metGuardian: wards.filter((w) => w.guardians.length).length,
|
|---|
| 240 | voorbeeld: wards[0] ? { naam: wards[0].naam, guardians: wards[0].guardians } : null,
|
|---|
| 241 | }, 'application/json');
|
|---|
| 242 | }
|
|---|
| 243 |
|
|---|
| 244 | const mInbox = p.match(/^\/u\/(w\d{3})\/inbox$/);
|
|---|
| 245 | if (mInbox && req.method === 'POST') {
|
|---|
| 246 | const w = staat.wards[mInbox[1]];
|
|---|
| 247 | if (!w) return stuur(res, 404, { error: 'no such ward' });
|
|---|
| 248 | let body = '';
|
|---|
| 249 | for await (const c of req) body += c;
|
|---|
| 250 | let uit = 'onleesbaar';
|
|---|
| 251 | try { uit = await verwerkInbox(w, JSON.parse(body)); }
|
|---|
| 252 | catch (e) { w.log.push({ at: new Date().toISOString(), fout: String(e.message) }); }
|
|---|
| 253 | // 202 hoe dan ook: een inbox die 4xx geeft op iets dat hij niet kent laat de
|
|---|
| 254 | // afzender eindeloos opnieuw proberen.
|
|---|
| 255 | return stuur(res, 202, { ok: true, uit });
|
|---|
| 256 | }
|
|---|
| 257 |
|
|---|
| 258 | const mOutbox = p.match(/^\/u\/(w\d{3})\/outbox$/);
|
|---|
| 259 | if (mOutbox) {
|
|---|
| 260 | return stuur(res, 200, {
|
|---|
| 261 | '@context': 'https://www.w3.org/ns/activitystreams',
|
|---|
| 262 | id: `${uriVan(mOutbox[1])}/outbox`, type: 'OrderedCollection', totalItems: 0, orderedItems: [],
|
|---|
| 263 | });
|
|---|
| 264 | }
|
|---|
| 265 |
|
|---|
| 266 | const mActor = p.match(/^\/u\/(w\d{3})\/?$/);
|
|---|
| 267 | if (mActor) {
|
|---|
| 268 | const w = staat.wards[mActor[1]];
|
|---|
| 269 | if (!w) return stuur(res, 404, { error: 'no such ward' });
|
|---|
| 270 | return stuur(res, 200, actorDoc(w));
|
|---|
| 271 | }
|
|---|
| 272 |
|
|---|
| 273 | const mLog = p.match(/^\/u\/(w\d{3})\/log$/);
|
|---|
| 274 | if (mLog) {
|
|---|
| 275 | const w = staat.wards[mLog[1]];
|
|---|
| 276 | return w ? stuur(res, 200, w.log, 'application/json') : stuur(res, 404, {}, 'application/json');
|
|---|
| 277 | }
|
|---|
| 278 |
|
|---|
| 279 | stuur(res, 404, { error: 'not found' }, 'application/json');
|
|---|
| 280 | });
|
|---|
| 281 |
|
|---|
| 282 | // De sleutels vooraf maken, zodat de eerste binnenkomende fetch niet op
|
|---|
| 283 | // keygen hoeft te wachten -- 100 keer 2048 bits duurt even.
|
|---|
| 284 | for (let i = 1; i <= N; i++) ward(i);
|
|---|
| 285 | bewaarStaat(staat);
|
|---|
| 286 |
|
|---|
| 287 | server.listen(PORT, '::1', () => {
|
|---|
| 288 | console.log(`[farm] ${N} wards op ${BASE} (luistert op 127.0.0.1:${PORT})`);
|
|---|
| 289 | });
|
|---|