diff --git a/src/proxy.ts b/src/proxy.ts index 79b79e0..fb42187 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -150,6 +150,66 @@ async function targetFor(name: string, access: MoshpitAccess): Promise` URLs); bare endings are skipped. */ +export function namesFromSitemap(xml: string): string[] { + const names = new Set() + // 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) + } + 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', () => {