From 7d1dbd826198822cf2fafd1fb2b63349e9b4e630 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 18 Oct 2026 02:33:30 +0000 Subject: [PATCH 1/2] Issue .pit.world certificates before the first visit pit.world sends HSTS with includeSubDomains, so a browser that has seen pit.world goes straight to https://.pit.world. Issuance only ran on a plain-http visit, so every name without a certificate failed its TLS handshake (alert 80) for those visitors: live.streaming.pit.world was down for desktops, and 17 of the 22 names with targets had no certificate. The server now sweeps the registry sitemap at start and every 15 minutes and asks pit-proxy-cert for every name the relay can reach and nginx cannot yet serve, through the existing weekly cap and once-an-hour guard. PIT_CERT_SWEEP=off disables it. Co-Authored-By: Claude Opus 5.5 --- src/proxy.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 14 ++++++++++- test/proxy.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 79b79e0..60b024e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -150,6 +150,65 @@ async function targetFor(name: string, access: MoshpitAccess): Promise` URLs); bare endings are skipped. */ +export function namesFromSitemap(xml: string): string[] { + const names = new Set() + for (const m of xml.matchAll(/[^<]*\/n\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)<\/loc>/gi)) { + const name = m[1]!.toLowerCase() + if (proxiedName(`${name}${PROXY_SUFFIX}`) === name) names.add(name) + } + return [...names] +} + +export type SweepDeps = { + certs: Certs + /** The registry's sitemap XML. */ + sitemap: () => Promise + /** Whether the relay can reach the name (the same check a visit makes). */ + reachable: (name: string) => Promise +} + +/** + * Make certificates before anyone visits. pit.world sends HSTS with + * includeSubDomains, so a browser that has seen pit.world goes straight to + * https://.pit.world and never makes the plain-http visit that used to + * trigger issuance: a name with no certificate failed its TLS handshake for + * every such visitor (live.streaming, 2026-10-18). This walks every name the + * registry lists and asks for the ones the relay can serve; certs.issue keeps + * the weekly cap and the once-an-hour-per-host guard. + */ +export async function sweepCerts(deps: SweepDeps): Promise<{ names: number; missing: string[]; asked: string[] }> { + const names = namesFromSitemap(await deps.sitemap()) + const missing: string[] = [] + const asked: string[] = [] + for (const name of names) { + const host = `${name}${PROXY_SUFFIX}` + if (deps.certs.has(host)) continue + if (!(await deps.reachable(name).catch(() => false))) continue + missing.push(host) + if (deps.certs.issue(host)) asked.push(host) + } + return { names: names.length, missing, asked } +} + +/** The sweep against the live registry, as serve() runs it. */ +export function registrySweep( + access: MoshpitAccess, + certs: Certs, + registry = 'https://pit.moshcode.sh', +): () => Promise<{ names: number; missing: string[]; asked: string[] }> { + return () => + sweepCerts({ + certs, + sitemap: async () => { + const res = await fetch(`${registry}/sitemap.xml`, { signal: AbortSignal.timeout(15_000) }) + if (!res.ok) throw new Error(`registry sitemap ${res.status}`) + return res.text() + }, + reachable: async (name) => (await targetFor(name, access)) !== null, + }) +} + const page = (status: number, title: string, body: string, extra: Record = {}) => new Response( `${esc(title)}

${esc(title)}

${body}
`, diff --git a/src/server.ts b/src/server.ts index 9d0bd99..79abe7e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,7 @@ import { looksLikeName, Registry } from './moshpit.js' import { RateLimiter } from './ratelimit.js' import { neighbors, randomSite, ringJson, ringSites, siteHref, useListings } from './ring.js' import { RingSync } from './ring-sync.js' -import { liveCerts, proxiedName, proxyRequest } from './proxy.js' +import { liveCerts, proxiedName, proxyRequest, registrySweep } from './proxy.js' import { Promoted } from './promoted.js' import { listingCard } from './listings-html.js' import { mountListings } from './listings-routes.js' @@ -359,6 +359,18 @@ export function serve(options: { port?: number; hostname?: string } = {}) { setInterval(daily, 24 * 3600 * 1000).unref() const trustProxy = (process.env.PIT_TRUST_PROXY ?? '1') === '1' const proxyDeps = { access: { resolve: (name: string) => registry.resolve(name), rootPem }, certs: liveCerts() } + // Certificates for every reachable name before its first visit (HSTS browsers never make the http one). + if (env.PIT_CERT_SWEEP !== 'off') { + const sweep = registrySweep(proxyDeps.access, proxyDeps.certs) + const run = () => + sweep() + .then((r) => { + if (r.missing.length) console.log(`cert sweep: ${r.names} names, missing ${r.missing.join(' ')}, asked ${r.asked.length}`) + }) + .catch((err) => console.error(`cert sweep failed: ${(err as Error).message}`)) + setTimeout(run, 20_000).unref() + setInterval(run, 15 * 60_000).unref() + } const serviceDeps = { ...proxyDeps, socialUrl: env.PIT_SOCIAL_URL === 'off' ? null : (env.PIT_SOCIAL_URL ?? 'http://127.0.0.1:8794'), diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 558b093..7586521 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -1,5 +1,59 @@ import { describe, expect, it } from 'bun:test' -import { decorate, joinFooter, proxiedName, rewriteCookie, rewriteUrls, unrewriteUrl } from '../src/proxy.ts' +import { decorate, joinFooter, namesFromSitemap, proxiedName, rewriteCookie, rewriteUrls, sweepCerts, unrewriteUrl, type Certs } from '../src/proxy.ts' + +describe('certificate sweep', () => { + const sitemap = ` + https://pit.moshcode.sh/pit + https://pit.moshcode.sh/n/streaming + https://pit.moshcode.sh/n/live.streaming + https://pit.moshcode.sh/n/family.ancestry + https://pit.moshcode.sh/n/kathleen.ettinger + https://pit.moshcode.sh/n/www.x + https://pit.moshcode.sh/n/live.streaming +` + + it('reads label.ending names from the registry sitemap, not bare endings or junk', () => { + expect(namesFromSitemap(sitemap).sort()).toEqual(['family.ancestry', 'kathleen.ettinger', 'live.streaming']) + }) + + function fakeCerts(have: string[], refuse: string[] = []) { + const issued: string[] = [] + const certs: Certs = { + has: (h) => have.includes(h), + issue: (h) => { + if (refuse.includes(h)) return false + issued.push(h) + return true + }, + } + return { certs, issued } + } + + it('asks for a certificate for every reachable name that has none, before any visit', async () => { + const { certs, issued } = fakeCerts(['family.ancestry.pit.world']) + const r = await sweepCerts({ + certs, + sitemap: async () => sitemap, + // kathleen.ettinger points at another Moshpit name, which the relay cannot reach. + reachable: async (n) => n !== 'kathleen.ettinger', + }) + expect(issued).toEqual(['live.streaming.pit.world']) + expect(r).toEqual({ names: 3, missing: ['live.streaming.pit.world'], asked: ['live.streaming.pit.world'] }) + }) + + it('reports a name it could not ask for (cap or recently asked) as missing', async () => { + const { certs } = fakeCerts([], ['live.streaming.pit.world']) + const r = await sweepCerts({ certs, sitemap: async () => sitemap, reachable: async () => true }) + expect(r.missing).toContain('live.streaming.pit.world') + expect(r.asked).not.toContain('live.streaming.pit.world') + }) + + it('skips a name whose reachability check throws', async () => { + const { certs, issued } = fakeCerts([]) + await sweepCerts({ certs, sitemap: async () => sitemap, reachable: async () => { throw new Error('dns') } }) + expect(issued).toEqual([]) + }) +}) describe('.pit.world', () => { it('maps hosts to Moshpit names, and leaves pit.world itself alone', () => { From 3b8e38cff4d1af1e33b80810d88c7cb536d13f9b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 18 Oct 2026 02:34:42 +0000 Subject: [PATCH 2/2] Sitemap names: flat regex (ThreatCrush CWE-1333), proxiedName validates Co-Authored-By: Claude Opus 5.5 --- src/proxy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/proxy.ts b/src/proxy.ts index 60b024e..fb42187 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -153,7 +153,8 @@ async function targetFor(name: string, access: MoshpitAccess): Promise` URLs); bare endings are skipped. */ export function namesFromSitemap(xml: string): string[] { const names = new Set() - for (const m of xml.matchAll(/[^<]*\/n\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)<\/loc>/gi)) { + // One flat class (no nested quantifiers, so no backtracking); proxiedName() decides what is a label.ending name. + for (const m of xml.matchAll(/\/n\/([a-z0-9.-]{1,253})<\/loc>/gi)) { const name = m[1]!.toLowerCase() if (proxiedName(`${name}${PROXY_SUFFIX}`) === name) names.add(name) }