source: Klonkt/tools/ward-farm/farm.mjs@ 66d97b8

main
Last change on this file since 66d97b8 was 66d97b8, checked in by roboburr <roboburr@…>, 5 weeks ago

Een kudde ward-daemons, om de caseload te kunnen meten (shaer-6wt, vervolg)

Bart: "precies de jeugdzorgmedewerker." Dat was geen bijval maar een correctie op
mijn prioritering, en de bredere versie ervan is dat ALLES wat we bouwden --
wardlijst, poortenpanelen, hulpvragen -- ontworpen is voor iemand met twee
kinderen. Dit is de maat die ontbrak: honderd wards, een guardian.

tools/ward-farm/farm.mjs is honderd AP-actors met eigen sleutels die de
FEP-633c-handshake aannemen. Geen Klonkt: geen timelines, geen weergave, geen
C2S. Precies genoeg om aan de andere kant van de lijn te staan.

ECHT REMOTE, en dat is geen detail. Ze wonen op een ANDERE origin dan dev.
Hing je ze onder dev zelf, dan ziet Klonkt ze als lokaal -- het co-locatiepad
kijkt of de URI met PUBLIC_BASE_URL begint -- en meet je de kortsluitroute in
plaats van de federatie.

offer-many.mjs laat dev zich honderd keer aanbieden, via de OUTBOX en dus langs
dezelfde code als de knop in de PWA. Geen tweede pad naar hetzelfde besluit; dat
is de les van vandaag. Veilig om opnieuw te draaien: wie al gecommit is wordt
overgeslagen.

Het automatische ja in de daemon staat er met een waarschuwing bij: bij een echt
kind is juist dat het moment waarop iemand moet nadenken.

Draait nu op de VPS in ~aiclaude/ward-farm, poort 3060, 100 sleutels gemaakt.
Wat ontbreekt is de publieke hostname: /etc/caddy is root, en een subdomein
toevoegen op een doos die ook andermans sites draait doe ik niet ongevraagd.

  • Property mode set to 100644
File size: 11.7 KB
Line 
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
27import http from 'node:http';
28import crypto from 'node:crypto';
29import fs from 'node:fs';
30import path from 'node:path';
31import { fileURLToPath } from 'node:url';
32
33const HERE = path.dirname(fileURLToPath(import.meta.url));
34const STATE = path.join(HERE, 'state.json');
35const BASE = process.env.BASE || 'https://wards.klonkt.com';
36const PORT = Number(process.env.PORT || 3060);
37const N = Number(process.env.WARDS || 100);
38const 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
45function laadStaat() {
46 try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { wards: {} }; }
47}
48function bewaarStaat(s) {
49 fs.writeFileSync(STATE, JSON.stringify(s, null, 2));
50}
51
52const staat = laadStaat();
53
54function 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
68const uriVan = (naam) => `${BASE}/u/${naam}`;
69const 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
79async 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
99async 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
117async 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
180function 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
204function 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
212const 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.
275for (let i = 1; i <= N; i++) ward(i);
276bewaarStaat(staat);
277
278server.listen(PORT, '127.0.0.1', () => {
279 console.log(`[farm] ${N} wards op ${BASE} (luistert op 127.0.0.1:${PORT})`);
280});
Note: See TracBrowser for help on using the repository browser.