Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,66 @@ async function targetFor(name: string, access: MoshpitAccess): Promise<Target |
return value
}

/** `label.ending` names in the registry's sitemap (`/n/<name>` URLs); bare endings are skipped. */
export function namesFromSitemap(xml: string): string[] {
const names = new Set<string>()
// 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<string>
/** Whether the relay can reach the name (the same check a visit makes). */
reachable: (name: string) => Promise<boolean>
}

/**
* Make certificates before anyone visits. pit.world sends HSTS with
* includeSubDomains, so a browser that has seen pit.world goes straight to
* https://<name>.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<string, string> = {}) =>
new Response(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${esc(title)}</title><link rel="stylesheet" href="${ASSET}"></head><body><main class="pit-proxy"><h1>${esc(title)}</h1>${body}</main></body></html>`,
Expand Down
14 changes: 13 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'),
Expand Down
56 changes: 55 additions & 1 deletion test/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0"?><urlset>
<url><loc>https://pit.moshcode.sh/pit</loc></url>
<url><loc>https://pit.moshcode.sh/n/streaming</loc></url>
<url><loc>https://pit.moshcode.sh/n/live.streaming</loc></url>
<url><loc>https://pit.moshcode.sh/n/family.ancestry</loc></url>
<url><loc>https://pit.moshcode.sh/n/kathleen.ettinger</loc></url>
<url><loc>https://pit.moshcode.sh/n/www.x</loc></url>
<url><loc>https://pit.moshcode.sh/n/live.streaming</loc></url>
</urlset>`

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('<name>.pit.world', () => {
it('maps hosts to Moshpit names, and leaves pit.world itself alone', () => {
Expand Down
Loading