source: Klonkt/src/services/ap-transport.js@ 7ccdced

main
Last change on this file since 7ccdced was 7ccdced, checked in by Robin <roboburr@…>, 25 hours ago

Een Delete van wat nog in de rij wacht: intern oplossen

Robins vraag (26-10): "Shouldn't a Delete on an undelivered Create resolve
internally for all unfederated ones?" Ja. bfa3ba3 zorgde er alleen voor dat de
Delete NA de wachtende Create aankwam -- maar de Create kwam dan nog steeds,
en wat gewist was bereikte zo alsnog een server die het nooit had gehad.

Nu haalt een Delete per inbox de wachtende Create en Update van datzelfde
object uit de rij (cancelUndelivered). Bewust alleen wat de inhoud draagt: een
Announce of Add van een gewist object is onschuldig, de ontvanger krijgt een
404 als hij het ophaalt.

Of de Delete zelf dan nog moet, hangt af van of er iets aangekomen KAN zijn.
Daarvoor de nieuwe kolom ap_delivery.tried: een rij die in de rij ging zonder
poging (de inbox wachtte al, of de server zat in zijn backoff) is nooit de deur
uit geweest; dan gaat er voor die inbox helemaal niets ('cancelled'). Is hij
wel geprobeerd, dan kan een time-out na verwerking hem hebben laten aankomen,
en gaat de Delete toch -- onschuldig als ze hem niet kenden. Rijen van voor de
kolom tellen als geprobeerd: dat is de veilige kant.

Daarbij twee directe verzenders die ik in bfa3ba3 had gemist, nu ook langs
deliverNowOrQueue: het bewerken van een antwoord (deliverOutboxUpdate -- een
Update vóór de wachtende Create wordt genegeerd, waarna de Create de oude tekst
brengt) en guardian-activiteiten (deliverToActor).

test/direct-sends-queue.test.js, nu negen toetsen: het gewiste antwoord dat
wachtte komt nooit aan (en de Delete gaat wel, want het was geprobeerd), een
nooit geprobeerd en gewist antwoord laat niets vertrekken, alleen Create en
Update van dat object en die inbox, en de bewerking en de guardian-activiteit
wachten. Acht mutaties apart gedraaid, elk gevangen; één (ook andere soorten
weghalen) eerst niet, waarop de bedoelde grens in de toets is vastgelegd.
Volledige suite 1380 groen.

Co-Authored-By: Claude Opus 5.5 <noreply@…>

  • Property mode set to 100644
File size: 28.4 KB
Line 
1/**
2 * ap-transport.js — het transport onder de federatie (stap 3 van shaer-drc).
3 *
4 * Alles wat hier woont raakt het netwerk of de sleutels, en niets erin weet
5 * iets van Notes, feeds of guardianship:
6 * - de SSRF-poort (safeFetch en zijn wachters) voor ELKE uitgaande fetch
7 * - de RSA-sleutels per actor
8 * - HTTP Signatures: tekenen (deliver, signedGetHeaders) en controleren
9 * (verifyRequest)
10 * - de bezorging met wachtrij en backoff (deliverWithRetry en de worker)
11 * - de ondertekende GET (signedGetJson) en zijn onbetekende broer (apGetJson)
12 *
13 * Verhuisd uit ActivityPubService.js, dat alles her-exporteert: bestaande
14 * importeurs merken niets. De afhankelijkheden wijzen alleen omlaag (db,
15 * ap-core, Node zelf) -- er mag hier nooit iets uit de dienstlaag bij.
16 */
17import crypto from 'crypto';
18import fs from 'fs';
19import dns from 'dns';
20import net from 'net';
21import db from '../config/database.js';
22import { actorId } from './ap-core.js';
23
24// ── SSRF guard for outbound fetches ───────────────────────────────
25// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
26// every outbound fetch must refuse hosts that resolve to private/loopback ranges
27// (cloud metadata, internal services) — on the initial host AND each redirect hop.
28function isBlockedIp(ip) {
29 if (!ip) return true;
30 const v = net.isIP(ip);
31 if (v === 4) {
32 const o = ip.split('.').map(Number);
33 return o[0] === 127 || o[0] === 10 || o[0] === 0
34 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
35 || (o[0] === 192 && o[1] === 168)
36 || (o[0] === 169 && o[1] === 254)
37 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
38 }
39 if (v === 6) {
40 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
41 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
42 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
43 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
44 }
45 return true; // not an IP literal we recognise → refuse
46}
47/**
48 * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine
49 * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).
50 *
51 * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van
52 * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat
53 * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de
54 * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij
55 * iemand hem expliciet vult.
56 *
57 * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst
58 * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort
59 * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in
60 * een dev-omgeving uitzetten, en dev-omgevingen worden productie.
61 *
62 * AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"
63 */
64const AP_ALLOW_HOSTS = new Set(
65 String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),
66);
67function isAllowedTestHost(u) {
68 if (!AP_ALLOW_HOSTS.size) return false;
69 return AP_ALLOW_HOSTS.has(u.host.toLowerCase());
70}
71async function assertPublicHost(hostname) {
72 // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP
73 // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de
74 // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met
75 // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input
76 // die hier nooit aankwam.
77 const naakt = String(hostname || '').replace(/^\[|\]$/g, '');
78 if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }
79 const addrs = await dns.promises.lookup(naakt, { all: true });
80 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
81}
82// One honest name on ALL outbound federation traffic (Robins vraag, 31-7):
83// safeFetch went out with the bare Node default before, and polite fediverse
84// citizens say who they are (some instances even refuse anonymous UAs). A
85// caller-provided User-Agent (the EmbedResolver) still wins.
86let _uaVer = '1.0';
87try { _uaVer = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url))).version || _uaVer; } catch { /* keep default */ }
88const KLONKT_UA = `Klonkt/${_uaVer} (+https://klonkt.com)`;
89
90export async function safeFetch(url, opts = {}, maxRedirects = 3) {
91 let target = url;
92 for (let hop = 0; ; hop++) {
93 const u = new URL(target); // throws on malformed → caller's catch
94 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
95 // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:
96 // een omleiding naar een ANDER intern adres blijft geweigerd.
97 if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);
98 const r = await fetch(target, {
99 ...opts,
100 headers: { 'User-Agent': KLONKT_UA, ...(opts.headers || {}) },
101 redirect: 'manual',
102 signal: AbortSignal.timeout(8000),
103 });
104 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
105 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
106 return r;
107 }
108}
109
110// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
111// Prepared lazily (NOT at module load) — the ap_keys table is created in
112// initializeDatabase(), which runs after this module is imported.
113let _sel, _ins;
114function keyStmts() {
115 if (!_sel) {
116 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
117 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
118 }
119 return { sel: _sel, ins: _ins };
120}
121
122export function getOrCreateKeys(slug) {
123 const { sel, ins } = keyStmts();
124 const row = sel.get(slug);
125 if (row) return row;
126 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
127 modulusLength: 2048,
128 publicKeyEncoding: { type: 'spki', format: 'pem' },
129 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
130 });
131 ins.run(slug, publicKey, privateKey);
132 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
133}
134
135// ── HTTP Signatures + delivery ────────────────────────────────────
136// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
137export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
138 const body = JSON.stringify(bodyObj);
139 const u = new URL(inboxUrl);
140 const date = new Date().toUTCString();
141 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
142 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
143 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
144 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
145 const r = await safeFetch(inboxUrl, {
146 method: 'POST',
147 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
148 body,
149 });
150 return r.status;
151}
152
153export async function fetchActor(url, opts = {}) {
154 // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
155 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
156 // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
157 // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
158 // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
159 // accounts eindeloos geweigerd, en precies die vier geven 401 op een
160 // onbetekende GET (shaer-afq).
161 //
162 // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
163 // actor-document op, en dat serveert Klonkt publiek.
164 //
165 // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
166 // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
167 // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
168 // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
169 // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
170 // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
171 // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
172 let doc = null;
173 try {
174 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
175 if (r.ok) {
176 const len = Number(r.headers.get('content-length') || 0);
177 if (len > 2_000_000) return null; // refuse oversized actor docs
178 doc = await r.json();
179 }
180 } catch { /* val door naar de ondertekende poging */ }
181 // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
182 // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
183 // telt als mislukt.
184 if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
185 if (!opts.asSlug) return doc;
186 const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
187 return (signed && signed.id) ? signed : doc;
188}
189
190// ── Delivery queue with retries ───────────────────────────────────
191// Outbound deliveries are tried immediately; on failure (down server, timeout,
192// non-2xx) they're queued and retried with backoff so a briefly-offline follower
193// doesn't silently miss the post. The signing key is NOT stored — the worker
194// re-derives it from the actor slug at send time.
195//
196// KLONKT DIE NIET ALTIJD AAN STAAT (shaer-xzab). Een Klonkt op een Mac thuis
197// slaapt, gaat de trein in, staat een weekend uit. Daarom:
198//
199// - Een bezorging VERLOOPT OP LEEFTIJD (5 dagen sinds created_at), niet na een
200// aantal pogingen (xzab.1). Zes pogingen waren ~4,5 uur, en de laatste trede
201// van 360 minuten werd nooit gebruikt: de telling was er eerder.
202// - OFFLINE ZIJN KOST GEEN POGINGEN (xzab.2). Mislukt een hele beurt op
203// netwerkniveau, naar meer dan één server en zonder één HTTP-antwoord, dan
204// ligt het aan ons: alles een paar minuten opschuiven, de telling ongemoeid,
205// en niets laten verlopen. Slapen kost al niets, want dan loopt de timer niet.
206// - BACKOFF PER SERVER, VOLGORDE PER INBOX (xzab.3). Eén mislukking naar een
207// server schuift al zijn rijen op. En per inbox oudste eerst, ook bij het
208// directe versturen: bij een venster van dagen mag een herhaalde Create niet
209// NA de Delete van dezelfde post aankomen.
210const DELIVERY_MAX_AGE_MS = 5 * 24 * 60 * 60 * 1000;
211// Minuten tot de volgende poging, op de poging die net mislukte; de laatste
212// trede herhaalt tot de bezorging verloopt.
213const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360, 720, 1440];
214// Hoe ver een beurt opschuift als WIJ offline blijken.
215const OFFLINE_RETRY_MIN = 2;
216
217export function deliveryBackoffMinutes(attemptsSoFar) {
218 return DELIVERY_BACKOFF_MIN[Math.min(Math.max(0, attemptsSoFar), DELIVERY_BACKOFF_MIN.length - 1)];
219}
220
221/** De host (met poort) van een inbox, in kleine letters; null als het geen URL is. */
222export function inboxHost(inbox) {
223 try { return new URL(inbox).host.toLowerCase(); } catch { return null; }
224}
225
226/** Is een bezorging zo oud dat hij opgegeven wordt? created_at is SQLite-UTC
227 * ('YYYY-MM-DD HH:MM:SS', zonder zone). */
228export function deliveryExpired(createdAt, now = Date.now()) {
229 if (!createdAt) return false;
230 const t = Date.parse(String(createdAt).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(createdAt) ? '' : 'Z'));
231 return Number.isFinite(t) && now - t >= DELIVERY_MAX_AGE_MS;
232}
233
234/** Faalde dit op NETWERKNIVEAU (geen HTTP-antwoord)? Een SSRF-weigering is
235 * dat niet: die is een besluit, en blijft een besluit als we weer online zijn. */
236function isNetworkError(e) {
237 return !/^ssrf/.test(String((e && e.message) || ''));
238}
239
240let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv, _hostDeliv, _offDeliv, _pendInbox, _hostWaits;
241function deliveryStmts() {
242 if (!_insDeliv) {
243 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, host, body, attempts, next_at, tried) VALUES (?,?,?,?,0,CURRENT_TIMESTAMP,?)');
244 // Alleen de OUDSTE openstaande rij per inbox: een nieuwere wacht op hem, ook
245 // als hij zelf al aan de beurt zou zijn. Op id, want dat is de volgorde
246 // waarin ze ontstonden.
247 _dueDeliv = db.prepare(`SELECT d.* FROM ap_delivery d
248 WHERE datetime(d.next_at) <= datetime('now')
249 AND NOT EXISTS (SELECT 1 FROM ap_delivery o WHERE o.inbox = d.inbox AND o.id < d.id)
250 ORDER BY d.id LIMIT 30`);
251 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
252 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
253 // Een server die faalde: al zijn rijen niet eerder dan de nieuwe poging.
254 _hostDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE host = ? AND datetime(next_at) < datetime(?)');
255 // Wij offline: opschuiven, de telling ongemoeid.
256 _offDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE id = ?');
257 _pendInbox = db.prepare('SELECT 1 FROM ap_delivery WHERE inbox = ? LIMIT 1');
258 _hostWaits = db.prepare("SELECT 1 FROM ap_delivery WHERE host = ? AND datetime(next_at) > datetime('now') LIMIT 1");
259 }
260 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv, host: _hostDeliv, off: _offDeliv, pendInbox: _pendInbox, hostWaits: _hostWaits };
261}
262// `tried`: ging er al een poging aan vooraf? Standaard ja, want zo riep iedereen
263// dit aan: na een mislukte deliver(). Alleen wie in de rij zet zonder te
264// proberen (deliverNowOrQueue bij een wachtende inbox of server) zegt nee.
265export function enqueueDelivery(slug, inbox, activity, { tried = true } = {}) {
266 if (!slug || !inbox || !activity) return;
267 try { deliveryStmts().ins.run(slug, inbox, inboxHost(inbox), JSON.stringify(activity), tried ? 1 : 0); } catch { /* ignore */ }
268}
269
270function objectIdOf(activity) {
271 const o = activity && activity.object;
272 return typeof o === 'string' ? o : (o && typeof o.id === 'string' ? o.id : null);
273}
274
275/**
276 * Een Delete van iets dat voor deze inbox nog in de rij staat (Robins vraag,
277 * 26-10: "shouldn't a Delete on an undelivered Create resolve internally?").
278 *
279 * Ja: haal de wachtende Create en Update van dat object uit de rij. Dan komt
280 * wat gewist is nooit meer aan bij een server die het nog niet had -- de
281 * volgorde per inbox alleen zorgde er wel voor dat de Delete NA de Create
282 * kwam, maar de Create kwam dan nog steeds.
283 *
284 * Of de Delete daarna nog hoeft, hangt af van of er iets aangekomen KAN zijn:
285 * een rij die al eens geprobeerd is, kan dat (een time-out nadat de ander hem
286 * verwerkte). Dan gaat de Delete toch; die is onschuldig als ze hem niet
287 * kenden, en zonder hem blijft de post daar misschien staan.
288 */
289export function cancelUndelivered(inbox, objectId) {
290 if (!inbox || !objectId) return { cancelled: 0, mayHaveArrived: false };
291 let cancelled = 0, mayHaveArrived = false;
292 try {
293 for (const r of db.prepare('SELECT id, body, attempts, tried FROM ap_delivery WHERE inbox = ?').all(inbox)) {
294 let a; try { a = JSON.parse(r.body); } catch { continue; }
295 if (!a || (a.type !== 'Create' && a.type !== 'Update') || objectIdOf(a) !== objectId) continue;
296 if (r.tried || r.attempts > 0) mayHaveArrived = true;
297 deliveryStmts().del.run(r.id);
298 cancelled++;
299 }
300 } catch { /* geen wachtrij te lezen: dan gewoon de Delete */ }
301 return { cancelled, mayHaveArrived };
302}
303// Record delivery health per follower so the followers list can flag dead accounts.
304// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
305// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
306let _fDelivOk, _fDelivErr;
307function markFollowerDelivery(slug, inbox, ok) {
308 if (!slug || !inbox) return;
309 try {
310 if (!_fDelivOk) {
311 _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
312 _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
313 }
314 (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
315 } catch { /* health tracking is non-fatal */ }
316}
317// Deliver now, or queue: zegt welke van de twee het werd ('sent' | 'queued'),
318// voor de aanroepers die tellen wat er meteen aankwam.
319//
320// Niet meteen als er voor deze inbox al iets in de wachtrij staat (dan zou dit
321// er voorbij gaan: een Delete vóór de Create die nog wacht), en niet als deze
322// server net faalde en in zijn backoff zit (dan kost het alleen acht seconden
323// time-out om hetzelfde te horen). Dan achteraan in de rij.
324//
325// DE ENIGE WEG om iets direct te versturen dat bij een mislukking in de rij
326// hoort (shaer-xzab.3). Drie plekken deden eerst zelf deliver() en pas bij een
327// mislukking enqueueDelivery(), en gingen zo langs de volgorde per inbox.
328//
329// 'cancelled': een Delete van iets dat deze inbox nooit kreeg; er gaat niets
330// heen (zie cancelUndelivered).
331export async function deliverNowOrQueue(slug, inbox, activity, keyId, privPem) {
332 if (!inbox) return 'queued';
333 if (activity && activity.type === 'Delete') {
334 const r = cancelUndelivered(inbox, objectIdOf(activity));
335 if (r.cancelled && !r.mayHaveArrived) return 'cancelled';
336 }
337 try {
338 const st = deliveryStmts();
339 if (st.pendInbox.get(inbox) || st.hostWaits.get(inboxHost(inbox))) { enqueueDelivery(slug, inbox, activity, { tried: false }); return 'queued'; }
340 } catch { /* geen wachtrij te lezen: gewoon proberen */ }
341 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return 'sent'; } } catch { /* queue below */ }
342 enqueueDelivery(slug, inbox, activity);
343 return 'queued';
344}
345// Deliver now; queue for retry if it fails.
346export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
347 await deliverNowOrQueue(slug, inbox, activity, keyId, privPem);
348}
349let _processingDeliv = false;
350export async function processDeliveryQueue() {
351 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
352 _processingDeliv = true;
353 try {
354 let rows;
355 try { rows = deliveryStmts().due.all(); } catch { return; }
356 if (!rows || !rows.length) return;
357 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
358 // Eerst alles proberen, pas daarna beslissen: of het aan ons lag, zie je
359 // pas aan de hele beurt.
360 const failed = []; // { row, network }
361 const down = new Set(); // servers die in deze beurt al faalden
362 const skipped = []; // rijen van zo'n server, niet meer geprobeerd
363 let answered = false; // kwam er ergens een HTTP-antwoord?
364 for (const row of rows) {
365 const host = row.host || inboxHost(row.inbox);
366 if (host && down.has(host)) { skipped.push(row); continue; }
367 let ok = false, network = false;
368 try {
369 const keys = getOrCreateKeys(row.slug);
370 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
371 answered = true;
372 ok = st >= 200 && st < 300;
373 } catch (e) { network = isNetworkError(e); }
374 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
375 failed.push({ row, network, host });
376 if (host) down.add(host);
377 }
378 if (!failed.length) return;
379
380 // Lag het aan ons? Niets beantwoord, alles op netwerkniveau, en naar meer
381 // dan één server -- bij één server is "hij ligt eruit" net zo waarschijnlijk.
382 const hosts = new Set(failed.map((f) => f.host));
383 const offline = !answered && failed.every((f) => f.network) && hosts.size > 1;
384 if (offline) {
385 const later = new Date(Date.now() + OFFLINE_RETRY_MIN * 60000).toISOString();
386 for (const r of [...failed.map((f) => f.row), ...skipped]) deliveryStmts().off.run(later, r.id);
387 console.warn('[AP] delivery: no network for', failed.length, 'deliveries to', hosts.size, 'servers; retrying in', OFFLINE_RETRY_MIN, 'min, attempts untouched');
388 return;
389 }
390
391 for (const { row, host } of failed) {
392 if (deliveryExpired(row.created_at)) {
393 markFollowerDelivery(row.slug, row.inbox, false);
394 deliveryStmts().del.run(row.id);
395 console.warn('[AP] delivery gave up after', row.attempts + 1, 'tries over 5 days →', row.inbox);
396 continue;
397 }
398 const next = new Date(Date.now() + deliveryBackoffMinutes(row.attempts) * 60000).toISOString();
399 deliveryStmts().bump.run(row.attempts + 1, next, row.id);
400 // De hele server wacht mee: zijn andere rijen niet eerder dan deze.
401 if (host) deliveryStmts().host.run(next, host, next);
402 }
403 } finally { _processingDeliv = false; }
404}
405let _delivTimer = null;
406export function startDeliveryWorker() {
407 if (_delivTimer) return;
408 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
409 if (_delivTimer.unref) _delivTimer.unref();
410}
411
412/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
413 * (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
414let _signSlug;
415export function anySigningSlug() {
416 if (_signSlug !== undefined) return _signSlug;
417 try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
418 catch { _signSlug = null; }
419 return _signSlug;
420}
421
422// Best-effort verification of an incoming signed request. Returns the sender's
423// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
424// Max clock skew for the signed Date header (replay window). Generous default to tolerate
425// federating servers with drifting clocks; an operator can widen it via env.
426const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
427export async function verifyRequest(req, asSlug = null) {
428 const sigH = req.headers['signature'];
429 if (!sigH) return null;
430 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
431 if (!p.keyId || !p.signature) return null;
432 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
433 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
434 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
435 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
436 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
437 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
438 if (!pem) return null;
439 // Bind the key to the actor it speaks for. Without this we hand back whatever
440 // `id` the fetched document claims, so anyone could host a document carrying a
441 // VICTIM's id next to their OWN public key, sign with their own private half,
442 // and be believed: the victim's server is never contacted. The caller decides on
443 // `verified.id`, so the identity has to come from where the key was FETCHED,
444 // never from what the document says about itself.
445 // Adds conditions only, and there is no exemption list on purpose: an
446 // "unless it's a known peer" escape hatch is exactly the door this closes.
447 // Note this does not narrow what we accept in practice, since the line above
448 // already requires the embedded publicKey object (an array or a bare URI
449 // reference never worked here).
450 const key = actor.publicKey;
451 try {
452 if (new URL(p.keyId).host !== new URL(actor.id).host) return null; // same origin as the key
453 if (key.id && key.id !== p.keyId) return null; // this key, not a neighbour's
454 if (key.owner && key.owner !== actor.id) return null; // and it belongs to this actor
455 } catch { return null; } // unparseable id or keyId
456 const hs = (p.headers || '(request-target) host date').split(/\s+/);
457 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
458 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
459 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
460 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
461 // against any. An attacker can't forge a match (no private key), so this only rescues the
462 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
463 let _pubHost = null;
464 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
465 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
466 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
467 const _sig = Buffer.from(p.signature, 'base64');
468 let ok = false;
469 for (const _h of _hosts) {
470 const line = hs.map((x) => x === '(request-target)'
471 ? `(request-target): ${_target}`
472 : x === 'host' ? `host: ${_h}`
473 : `${x}: ${req.headers[x] || ''}`).join('\n');
474 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
475 }
476 // Replay defence: the Date header must be signed and recent. A captured signed request
477 // replayed later (or with a swapped body) is rejected.
478 if (ok) {
479 if (!hs.includes('date')) ok = false;
480 else {
481 const t = Date.parse(req.headers['date'] || '');
482 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
483 }
484 }
485 // Digest is MANDATORY when the request carries a body: without a signed digest the body
486 // isn't covered by the signature and could be swapped on a replay.
487 if (ok && req.rawBody && req.rawBody.length) {
488 if (!hs.includes('digest')) ok = false;
489 else {
490 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
491 if (req.headers['digest'] !== exp) ok = false;
492 }
493 }
494 return ok ? actor : null;
495}
496
497// A generic SSRF-safe AP GET (collections / pages).
498/**
499 * A signed GET as one of our local actors (friends-history, 30-7): the remote
500 * server can then recognise the caller and serve what THAT caller may see,
501 * exactly like the guardian's authorized fetch. The signature covers
502 * (request-target) host date, the set verifyRequest checks.
503 */
504/**
505 * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
506 * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
507 * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
508 * krijgt daar terecht een 403).
509 */
510export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
511 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
512 if (!base || !slug) return null;
513 const me = actorId(base, slug);
514 const keys = getOrCreateKeys(slug);
515 const u = new URL(url);
516 const date = new Date().toUTCString();
517 const target = `${u.pathname}${u.search || ''}`;
518 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
519 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
520 return {
521 Accept: accept,
522 Date: date,
523 Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
524 };
525}
526
527export async function signedGetJson(slug, url, onStatus) {
528 try {
529 const headers = signedGetHeaders(slug, url);
530 if (!headers) return apGetJson(url);
531 const r = await safeFetch(url, { headers });
532 // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
533 // niet", en dat is te weinig om een WEIGERING van een STORING te
534 // onderscheiden. Wie geen callback meegeeft merkt hier niets van.
535 if (typeof onStatus === 'function') onStatus(r.status);
536 if (!r.ok) return null;
537 const len = Number(r.headers.get('content-length') || 0);
538 if (len > 3_000_000) return null;
539 return await r.json();
540 } catch { return null; }
541}
542
543export async function apGetJson(url) {
544 try {
545 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
546 if (!r.ok) return null;
547 const len = Number(r.headers.get('content-length') || 0);
548 if (len > 3_000_000) return null;
549 return await r.json();
550 } catch { return null; }
551}
Note: See TracBrowser for help on using the repository browser.