/** * Een kudde ward-daemons, om de guardian met een CASELOAD te kunnen testen. * * Aanleiding (Bart, 8-8): ik noemde "tientallen hulpvragen bij een guardian" een * randgeval en zette het op P3. Bart: "precies de jeugdzorgmedewerker." Dat is * geen uitzondering maar een beroep, en alles wat we bouwden -- de wardlijst, de * poortenpanelen, de hulpvragen -- is ontworpen voor iemand met twee kinderen. * Deze farm is de maat die ontbrak. * * ECHT REMOTE, en dat is geen detail. De wards wonen op een ANDERE origin dan * dev.klonkt.com. Zou je ze onder dev zelf hangen, dan ziet Klonkt ze als lokaal * (het co-locatie-pad kijkt of de URI met PUBLIC_BASE_URL begint) en test je de * kortsluitroute in plaats van de federatie. Dan meet je het verkeerde. * * Wat dit WEL is: honderd AP-actors die de FEP-633c-handshake kunnen aannemen en * een hulpvraag kunnen sturen. Wat het NIET is: een Klonkt. Geen opslag van * timelines, geen weergave, geen C2S. Precies genoeg om aan de andere kant van * de lijn te staan. * * Sleutels staan in state.json en worden EEN keer gemaakt. Weggooien betekent * dat elke ward een vreemde wordt voor dev, en dat de handshake opnieuw moet. * * node farm.mjs # start op poort 3060 * WARDS=100 PORT=3060 node farm.mjs */ import http from 'node:http'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const STATE = path.join(HERE, 'state.json'); // GEEN TLS, en dat hoort ook zo (Barts vraag, 8-8: "waarom via https? gewoon // localhost ::1"). Een certificaat en een publieke hostname toevoegen aan een // doos die ook andermans sites draait, om honderd neptestkinderen te kunnen // bereiken, is de verkeerde prijs. Klonkt laat dit adres door via AP_ALLOW_HOSTS // -- een precies host:poort-paar, geen "loopback mag"-vlag. // // EN HET TOETST IETS DAT WE TOCH MOESTEN KUNNEN: http met HTTP-signatures, zonder // TLS. Dat is een echt federatiegeval (interne netwerken, onion), en tot nu toe // was het nergens uitgeprobeerd. const BASE = process.env.BASE || 'http://[::1]:3060'; const PORT = Number(process.env.PORT || 3060); const N = Number(process.env.WARDS || 100); const HOST = new URL(BASE).host; // ── Staat ──────────────────────────────────────────────────────────────── // Een plat bestand. Dit is een testkudde, geen instance: als de staat weg is // begin je opnieuw, en dat hoort ook zo -- anders sluipt er toestand in waarvan // niemand meer weet waar hij vandaan kwam. function laadStaat() { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { wards: {} }; } } function bewaarStaat(s) { fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); } const staat = laadStaat(); function ward(n) { const naam = `w${String(n).padStart(3, '0')}`; if (!staat.wards[naam]) { const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); staat.wards[naam] = { naam, publicKey, privateKey, guardians: [], offers: {}, log: [] }; bewaarStaat(staat); } return staat.wards[naam]; } const uriVan = (naam) => `${BASE}/u/${naam}`; const naamUit = (uri) => { const m = String(uri || '').match(/\/u\/(w\d{3})\/?$/); return m ? m[1] : null; }; // ── Ondertekenen ───────────────────────────────────────────────────────── // (request-target) host date digest -- wat Mastodon stuurt en wat Klonkt // verwacht. Digest is verplicht zodra er een body is: zonder ondertekende // digest bewijst een handtekening niets over de inhoud. async function bezorg(w, inbox, activity) { const body = JSON.stringify(activity); const u = new URL(inbox); const date = new Date().toUTCString(); const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64'); const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`; const signature = crypto.sign('sha256', Buffer.from(signingString), w.privateKey).toString('base64'); const sig = `keyId="${uriVan(w.naam)}#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`; const r = await fetch(inbox, { method: 'POST', headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig, }, body, }); return r.status; } async function inboxVan(actorUri) { const r = await fetch(actorUri, { headers: { Accept: 'application/activity+json' } }); if (!r.ok) throw new Error(`actor ${actorUri}: ${r.status}`); const doc = await r.json(); return doc.inbox || (doc.endpoints && doc.endpoints.sharedInbox); } // ── De handshake (FEP-633c 3.1) ────────────────────────────────────────── // // De kandidaat biedt aan met een Offer van Relationship{subject: ward, object: // kandidaat}, gericht aan de ward en aan de bestaande guardians. Elke partij // accepteert. Wij zijn de WARD: wij accepteren, gericht aan alle anderen, en de // kandidaat sluit af met de laatste Accept -- dat is de commit. // // AUTOMATISCH JA, en alleen omdat dit een testkudde is. Bij een echt kind is // juist dit het moment waarop iemand moet nadenken; dat staat er hier expliciet // bij zodat deze code nooit ergens anders terechtkomt. // // HET OFFER IS AL DE TOESTEMMING VAN DE KANDIDAAT, en daar zat mijn fout. Ik // wachtte op een afsluitende Accept van de kandidaat, en die komt bij een VRIJE // ward nooit -- terecht. Klonkt zegt het met zoveel woorden in handshake.js: // "The Offer carries the candidate's agreement -- a free ward then commits on // its own accept." Dev deed dus precies wat het hoorde te doen; mijn daemon // bleef wachten op iets dat het protocol niet stuurt. // // De afsluitende Accept van de kandidaat bestaat WEL, maar in het meerpartijen- // geval: heeft het kind al guardians, dan moeten die eerst ja zeggen en sluit de // kandidaat af. Vandaar de telling hieronder in plaats van "commit bij mijn // eigen ja" -- dat laatste zou werken voor de eerste guardian en stilletjes fout // zijn voor de tweede. /** * Alles wat er gebeurt, en meteen naar schijf. * * Stond eerst los: een push in het geheugen, en wegschrijven alleen in de takken * die toch al iets bewaarden. Gevolg: /log en state.json spraken elkaar tegen -- * de een had een Follow die de ander niet kende. Bij een testkudde die je gebruikt * om een fout te vinden is dat het ergste wat je kunt hebben: dan zoek je in * bewijs dat zelf niet klopt. */ function noteer(w, regel) { w.log.push({ at: new Date().toISOString(), ...regel }); if (w.log.length > 80) w.log = w.log.slice(-80); bewaarStaat(staat); } /** * Een ja bijschrijven, en committen zodra iedereen ja heeft gezegd. * * Dezelfde regel als readyToCommit in Klonkt: de ward, de kandidaat, en -- als * het kind er al had -- minstens een van de bestaande guardians. */ function telAccept(w, offerId, wie) { const off = w.offers[offerId]; if (!off || off.commit) return 'onbekend offer'; if (wie && !off.accepts.includes(wie)) off.accepts.push(wie); const iedereen = off.partijen.every((p) => off.accepts.includes(p)); if (!iedereen) { noteer(w, { wacht_op: off.partijen.filter((p) => !off.accepts.includes(p)) }); return 'nog niet compleet'; } off.commit = new Date().toISOString(); if (!w.guardians.includes(off.kandidaat)) w.guardians.push(off.kandidaat); noteer(w, { commit: off.kandidaat }); return 'guardian erbij'; } async function verwerkInbox(w, activity) { const type = Array.isArray(activity.type) ? activity.type[0] : activity.type; const actor = typeof activity.actor === 'string' ? activity.actor : (activity.actor && activity.actor.id); noteer(w, { type, actor }); const o = activity.object; const relType = o && (Array.isArray(o.type) ? o.type[0] : o.type); if (type === 'Offer' && relType === 'Relationship') { const subject = typeof o.subject === 'string' ? o.subject : (o.subject && o.subject.id); if (subject !== uriVan(w.naam)) return 'niet voor mij'; const kandidaat = typeof o.object === 'string' ? o.object : (o.object && o.object.id); // De partijen: de kandidaat, ikzelf, en mijn bestaande guardians (die staan // in `to`). De kandidaat telt meteen als ja -- zijn Offer IS zijn instemming. const partijen = [...new Set([ kandidaat, uriVan(w.naam), ...(Array.isArray(activity.to) ? activity.to : []), ])].filter(Boolean); w.offers[activity.id] = { kandidaat, at: Date.now(), partijen, accepts: [kandidaat] }; bewaarStaat(staat); // Accepteren, gericht aan iedereen die in `to` stond plus de kandidaat, zodat // elke kopie van de telling dezelfde kant op loopt. const aan = [...new Set([...(Array.isArray(activity.to) ? activity.to : []), kandidaat])] .filter((x) => x && x !== uriVan(w.naam)); for (const doel of aan) { try { const inbox = await inboxVan(doel); const code = await bezorg(w, inbox, { '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://shaer.klonkt.com/ns#' }], id: `${uriVan(w.naam)}/accepts/${crypto.randomUUID()}`, // `object` is de OFFER-ID, niet het hele Offer. Zo stuurt Klonkt hem // ook (fanout in handshake.js), en een testkudde die er net anders // uitziet dan een echte implementatie laat je een verschil onderzoeken // dat je zelf gemaakt hebt. type: 'Accept', actor: uriVan(w.naam), to: aan, object: activity.id, }); // De statuscode erbij. "Hij stuurde een Accept" is een aanname zolang je // niet weet wat de andere kant ervan vond. noteer(w, { verstuurd: 'Accept', naar: doel, code }); } catch (e) { noteer(w, { fout: String(e.message), naar: doel }); } } telAccept(w, activity.id, uriVan(w.naam)); return 'geaccepteerd'; } if (type === 'Accept') { // Een ja van een mede-partij (een bestaande guardian, of de kandidaat die in // het meerpartijengeval afsluit). `object` is de offer-id. const offerId = typeof o === 'string' ? o : (o && o.id); if (!offerId || !w.offers[offerId]) return 'onbekend offer'; return telAccept(w, offerId, actor); } if (type === 'Undo') { const rel = o && (Array.isArray(o.type) ? o.type[0] : o.type) === 'Relationship' ? o : null; const wie = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id)); if (wie) { w.guardians = w.guardians.filter((g) => g !== wie); bewaarStaat(staat); return 'guardian eraf'; } } return 'genegeerd'; } // ── Documenten ─────────────────────────────────────────────────────────── function actorDoc(w) { const id = uriVan(w.naam); const doc = { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', { shaer: 'https://shaer.klonkt.com/ns#' }, ], id, type: 'Person', preferredUsername: w.naam, name: `Ward ${w.naam}`, inbox: `${id}/inbox`, outbox: `${id}/outbox`, followers: `${id}/followers`, following: `${id}/following`, publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: w.publicKey }, }; // 2.1: alleen aanwezig als er echt guardians zijn. Een lege lijst zou zeggen // "dit kind heeft er geen", en dat is iets anders dan "nog niet gecommit". if (w.guardians.length) doc['shaer:guardians'] = w.guardians; return doc; } function stuur(res, code, body, type = 'application/activity+json') { const s = typeof body === 'string' ? body : JSON.stringify(body); res.writeHead(code, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(s) }); res.end(s); } // ── Server ─────────────────────────────────────────────────────────────── const server = http.createServer(async (req, res) => { const u = new URL(req.url, BASE); const p = u.pathname; if (p === '/.well-known/webfinger') { const m = String(u.searchParams.get('resource') || '').match(/^acct:(w\d{3})@/); if (!m) return stuur(res, 404, { error: 'not found' }, 'application/json'); const id = uriVan(m[1]); return stuur(res, 200, { subject: `acct:${m[1]}@${HOST}`, links: [{ rel: 'self', type: 'application/activity+json', href: id }], }, 'application/jrd+json'); } if (p === '/status') { const wards = Object.values(staat.wards); return stuur(res, 200, { wards: wards.length, metGuardian: wards.filter((w) => w.guardians.length).length, voorbeeld: wards[0] ? { naam: wards[0].naam, guardians: wards[0].guardians } : null, }, 'application/json'); } const mInbox = p.match(/^\/u\/(w\d{3})\/inbox$/); if (mInbox && req.method === 'POST') { const w = staat.wards[mInbox[1]]; if (!w) return stuur(res, 404, { error: 'no such ward' }); let body = ''; for await (const c of req) body += c; let uit = 'onleesbaar'; try { uit = await verwerkInbox(w, JSON.parse(body)); } catch (e) { noteer(w, { fout: String(e.message) }); } // 202 hoe dan ook: een inbox die 4xx geeft op iets dat hij niet kent laat de // afzender eindeloos opnieuw proberen. return stuur(res, 202, { ok: true, uit }); } const mOutbox = p.match(/^\/u\/(w\d{3})\/outbox$/); if (mOutbox) { return stuur(res, 200, { '@context': 'https://www.w3.org/ns/activitystreams', id: `${uriVan(mOutbox[1])}/outbox`, type: 'OrderedCollection', totalItems: 0, orderedItems: [], }); } const mActor = p.match(/^\/u\/(w\d{3})\/?$/); if (mActor) { const w = staat.wards[mActor[1]]; if (!w) return stuur(res, 404, { error: 'no such ward' }); return stuur(res, 200, actorDoc(w)); } const mLog = p.match(/^\/u\/(w\d{3})\/log$/); if (mLog) { const w = staat.wards[mLog[1]]; return w ? stuur(res, 200, w.log, 'application/json') : stuur(res, 404, {}, 'application/json'); } stuur(res, 404, { error: 'not found' }, 'application/json'); }); // De sleutels vooraf maken, zodat de eerste binnenkomende fetch niet op // keygen hoeft te wachten -- 100 keer 2048 bits duurt even. for (let i = 1; i <= N; i++) ward(i); bewaarStaat(staat); server.listen(PORT, '::1', () => { console.log(`[farm] ${N} wards op ${BASE} (luistert op [::1]:${PORT})`); });