source: Klonkt/tools/ward-farm/farm.mjs@ 7c38a09

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

De testkudde mag zelf geen bron van twijfel zijn (shaer-6wt)

Barts opdracht: zoeken aan de kant van de daemon. Dat leverde geen fout in de
handshake op, maar wel twee dingen die het ONDERZOEK onbetrouwbaar maakten.

HET BEWIJS KLOPTE ZELF NIET. /log toonde een Follow die state.json niet kende: er
werd alleen weggeschreven in takken die toch al iets bewaarden. Bij een kudde die
je gebruikt om een fout te vinden is dat het ergste wat er is -- dan zoek je in
bewijs dat zelf niet deugt. Elke regel gaat nu meteen naar schijf.

En UITGAAND wordt nu ook genoteerd, met de statuscode erbij. "Hij stuurde een
Accept" was tot dat moment een aanname; nu staat er dat dev er 202 op gaf, en pas
daarmee is de daemon-kant echt uitgesloten.

MIJN ACCEPT WEEK AF VAN KLONKT: ik stuurde het hele Offer als object, Klonkt
stuurt daar een id-string. Het gedrag verandert er niet van, maar een testkudde
die er anders uitziet dan een echte implementatie laat je een verschil
onderzoeken dat je zelf hebt gemaakt.

Wat het spoor nu zegt, en dat is nog een LEAD en geen conclusie: dev commit wel
(het volgt de ward) maar stuurt zijn afsluitende Accept niet, dus de ward houdt
een lege shaer:guardians. In handshake.js doet de uitgaande accept-tak een
recordAccept EN een fanout; de inkomende tak op regel 600 alleen recordAccept en
maybeCommit. Waarom het bij @mee wel lukte is nog onverklaard, en zonder die
verklaring is een fix een gok.

Los daarvan: er staat nu een start.sh. De farm stierf drie keer mee met mijn
ssh-sessie, en twee van die keren zocht ik naar een fout terwijl er domweg niets
draaide.

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