source: Klonkt/tools/ward-farm/farm.mjs@ df70d8e

main
Last change on this file since df70d8e was df70d8e, checked in by roboburr <roboburr@…>, 4 weeks ago

Het Offer IS de toestemming van de kandidaat (shaer-6wt)

Uitgezocht, en de fout was van mij. Ik gaf gisteren een "lead" af naar
handshake.js regel 600 -- de inkomende accept-tak die geen fanout doet. Die lead
was onjuist en ik trek hem terug.

Klonkt zegt het met zoveel woorden, en die regel had ik moeten lezen voordat ik
naar zijn code wees:

"The Offer carries the candidate's agreement -- a free ward then commits on

its own accept."

De afsluitende Accept van de kandidaat waar mijn daemon op wachtte, komt bij een
VRIJE ward nooit, en dat hoort ook niet. Dev deed precies wat het moest doen; de
daemon bleef wachten op iets dat het protocol niet stuurt. Dat verklaart ook @mee
meteen: haar Klonkt implementeert dit goed en committeerde op haar eigen ja.

De daemon telt nu echt. Partijen zijn de kandidaat, de ward zelf en de bestaande
guardians uit to; de kandidaat staat er meteen als ja in. Committen als
IEDEREEN ja heeft gezegd -- niet "commit bij mijn eigen ja", want dat werkt voor
de eerste guardian en is stilletjes fout voor de tweede. Wat nog ontbreekt wordt
gelogd, zodat een half afgemaakte handshake zichtbaar is in plaats van stil.

En de telling in offer-many: 202 is hier de NORMALE uitkomst, geen mislukking.
Het scherm zei "verstuurd 0" terwijl alles goed ging, en een verzonnen
mislukking is net zo verwarrend als een verzwegen fout.

RESULTAAT: 100 wards, beide kanten eens, over http met HTTP-signatures en zonder
TLS. Eerste meting van de caseload: 1414 poortrijen in 26 ms, maar de wards-queue
die de apps elke verversing ophalen is 222 KB.

  • Property mode set to 100644
File size: 14.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');
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.
44const BASE = process.env.BASE || 'http://[::1]:3060';
45const PORT = Number(process.env.PORT || 3060);
46const N = Number(process.env.WARDS || 100);
47const 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
54function laadStaat() {
55 try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { wards: {} }; }
56}
57function bewaarStaat(s) {
58 fs.writeFileSync(STATE, JSON.stringify(s, null, 2));
59}
60
61const staat = laadStaat();
62
63function 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
77const uriVan = (naam) => `${BASE}/u/${naam}`;
78const 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
88async 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
108async 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 */
148function 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 */
160function 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
172async 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
240function 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
264function 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
272const 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.
335for (let i = 1; i <= N; i++) ward(i);
336bewaarStaat(staat);
337
338server.listen(PORT, '::1', () => {
339 console.log(`[farm] ${N} wards op ${BASE} (luistert op [::1]:${PORT})`);
340});
Note: See TracBrowser for help on using the repository browser.