source: Klonkt/tools/ward-farm/farm.mjs@ 29eb745

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

Doorslaggevend schuift mee, en de daemons hoeven geen TLS

Drie dingen van Bart (8-8).

  1. DOORSLAGGEVEND IS EEN STAND, GEEN MOMENT -- een fout in wat ik een uur eerder

bouwde. Ik berekende shaer:decisive een keer bij het doorsturen en bevroor het.
Bij vijf guardians staat er dan "je beslist niets", en zodra er een ja bij komt IS
elk van de anderen de doorslag. Precies de stille kant: het scherm zwijgt op het
moment dat het moet spreken. Na elke stem die het besluit open laat krijgen de
overgeblevenen nu een herziene stand, en alleen zij.

  1. DE VOLGPOORT MAG UITGEROLD (Barts akkoord). Zit in deze reeks.
  1. GEEN TLS VOOR DE TESTKUDDE. Barts vraag: waarom via https, gewoon localhost

::1. Terecht -- een certificaat en een publieke hostname toevoegen aan een doos
die ook andermans sites draait, om honderd neptestkinderen te bereiken, is de
verkeerde prijs.

Daarvoor mag de SSRF-poort gericht open: AP_ALLOW_HOSTS, een lijst van precieze
host:poort-paren. Geen "loopback is oke"-vlag. [::1]:3060 opent niet 127.0.0.1,
niet 3061, en niets in het interne netwerk -- want een brede vlag zet de
bescherming uit in een dev-omgeving, en dev-omgevingen worden productie. Leeg
tenzij een beheerder hem vult, en dat is de stand van elke productie-instance.

En het toetst iets dat we toch moesten kunnen: http MET HTTP-signatures, zonder
TLS. Een echt federatiegeval, tot nu toe nergens uitgeprobeerd.

ONDERWEG EEN LATENTE FOUT GEVONDEN. URL.hostname geeft een IPv6-literal met
blokhaken, en net.isIP herkent die vorm niet -- elk IPv6-adres viel door naar de
DNS-tak en strandde daar op ENOTFOUND. Geweigerd, maar per ongeluk en met de
verkeerde reden; isBlockedIp strippte die haken al en verwachtte dus input die
nooit aankwam. Nu wordt een IPv6-literal echt gecontroleerd.

EN EEN SLAAGTEST, want die ontbrak. Al mijn SSRF-toetsen controleerden alleen
weigeringen: de lijst helemaal negeren gaf nul rode toetsen, en de farm zou
stilletjes nooit werken. Nu bewijst er een dat wat op de lijst staat er ook
doorheen komt.

Suite 755/755. Drie mutaties gecontroleerd.

  • Property mode set to 100644
File size: 12.2 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
126async 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
189function 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
213function 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
221const 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.
284for (let i = 1; i <= N; i++) ward(i);
285bewaarStaat(staat);
286
287server.listen(PORT, '::1', () => {
288 console.log(`[farm] ${N} wards op ${BASE} (luistert op 127.0.0.1:${PORT})`);
289});
Note: See TracBrowser for help on using the repository browser.