// Reject values that would break out of an Apache directive when interpolated
// (whitespace, quotes, angle brackets, CR/LF). Defense-in-depth on top of the
// registry's input validation — a malformed value must never reach the config
// that gets deployed, especially now that deploy() rolls back on configtest
// failure rather than silently shipping garbage.
// Per-route request timeout in seconds for PLAIN HTTP proxying.
//
// ⛔ NOT the same thing as the vhost-level `ProxyTimeout 3600` below, and the split is the point.
// 3600 exists for WEBSOCKETS — a firehose or spacedust subscription is idle for long stretches and must
// not be severed. But it also applied to ordinary HTTP, so a backend that accepted a connection and
// never answered held an Apache worker for up to an HOUR per request.
//
// On 2026-08-24 that took the whole proxy down: both favela-web backends restarted at once (autoupdate
// on both), every request to them hung, and Apache stopped serving EVERY vhost — including ones whose
// backends were perfectly healthy. Recovering it required a restart.
//
// WebSocket upgrades are routed by a `RewriteRule [P]`, which is NOT affected by a ProxyPass timeout,
// so bounding the ProxyPass costs WS nothing.
//
// 300s, matching what the balancer path already used: 5s was tried and was too aggressive — a data
// stall on a large transfer (the pear-git bootstrap tarball) tripped failover into a 502, issue #101.
// Liveness is a separate concern, handled by the TCP health checks. Override per host/service with
// `timeout`.
const DEFAULT_PROXY_TIMEOUT = 300
function assertSafe (value, kind) {
if (typeof value !== 'string' || value.length === 0 || /[\s"'<>\n\r]/.test(value)) {
throw new Error(`unsafe ${kind} for Apache config: ${JSON.stringify(value)}`)
}
}
// Host-routed vhosts (a distinct mode). Emits full <VirtualHost *:443> blocks that
// proxy a whole subdomain to a backend at the URL ROOT. This file is deployed to
// sites-enabled/ (loaded at server/top-level context) — NOT the apex-vhost include,
// because a <VirtualHost> cannot be nested inside another <VirtualHost>. TLS is supplied
// automatically by mod_md for any *.7rnx.net ServerName (no cert directives needed).
export function generateVhosts (registry) {
const lines = []
lines.push('# Auto-generated by 7rnx-proxy — DO NOT EDIT MANUALLY')
lines.push(`# Generated: ${new Date().toISOString()}`)
lines.push('#')
lines.push('# Host-routed reverse proxies (vhost mode). Loaded at top level via sites-enabled/.')
lines.push('# TLS is supplied by mod_md for any *.7rnx.net ServerName.')
lines.push('')
// ── Connection reuse (server context, applies to every site on this box) ──
//
// Every client here is remote: ~95ms RTT to the VPS, and a home backend adds
// ~110ms more over the VPN. A fresh TCP+TLS handshake therefore costs ~210ms —
// measured as HALF of a cold page load (fresh 427ms vs reused 210ms on
// /git/repos). Debian's default KeepAliveTimeout 5 closes the connection
// between one page view and the next click, so ordinary browsing re-pays that
// handshake constantly and reads to users as "it randomly needs to warm up".
//
// Emitted at TOP LEVEL (not inside a <VirtualHost>) so it covers the apex
// path-routed site — which serves /git — and not just the host-routed vhosts
// below. apache2.conf sets this at line ~124 and includes sites-enabled/ at
// ~238, so this later definition wins.
lines.push('# Override apache2.conf\'s default of 5 (see git history for the latency measurements).')
lines.push('KeepAliveTimeout 65')
lines.push('')
// h2 multiplexes a whole page over ONE connection, so the ~210ms TCP+TLS
// handshake is paid once rather than per parallel connection. `Protocols` is
// a CORE directive (2.4.17+), NOT one of mod_http2's, so it is safe to ship
// before the module is enabled — an unavailable protocol is simply never
// negotiated and clients stay on http/1.1. Requires mpm_event (this box) —
// mod_http2 refuses to serve under mpm_prefork.
lines.push('# h2 requires `a2enmod http2` + mpm_event; harmless before the module is enabled.')
lines.push('Protocols h2 http/1.1')
lines.push('')
const nodeMap = new Map()
for (const node of registry.nodes) nodeMap.set(node.petname, node)
// ★ Wildcard vhosts MUST be emitted last. Apache matches name-based vhosts in configuration
// order, so a `ServerAlias *.7rnx.net` block shadows every specific vhost defined after it.
// Registry order is insertion order, so before this sort any host registered AFTER the
// wildcard silently resolved to the wildcard's backend — pds/p2p only ever worked because
// they predate it. Found when ap.7rnx.net (added later) was answered by the atproto membrane
// on :3399 instead of ap-bridge on :3400, with no error anywhere to indicate why.
const orderedHosts = [...(registry.hosts || [])].sort((a, b) => {
const aw = a.serverName.startsWith('*.') ? 1 : 0
const bw = b.serverName.startsWith('*.') ? 1 : 0
return aw - bw
})
for (const h of orderedHosts) {
const node = nodeMap.get(h.node)
if (!node) continue
assertSafe(node.ip, 'node ip')
assertSafe(h.name, 'host name')
if (!/^(\*\.)?[a-z0-9.-]+$/i.test(h.serverName)) {
throw new Error(`unsafe serverName for Apache config: ${JSON.stringify(h.serverName)}`)
}
const backend = `http://${node.ip}:${h.port}`
const wildcard = h.serverName.startsWith('*.')
lines.push(`# === ${h.name} → ${h.serverName} → ${node.ip}:${h.port} ===`)
lines.push('<VirtualHost *:443>')
// A wildcard needs a concrete ServerName + a ServerAlias for the glob (Apache matches
// exact ServerNames first, so specific vhosts like pds.7rnx.net still win). Subdomains
// only — *.7rnx.net does not match the apex 7rnx.net (its path-based site is untouched).
if (wildcard) {
lines.push(` ServerName ${h.serverName.replace('*', 'wildcard')}`)
lines.push(` ServerAlias ${h.serverName}`)
} else {
lines.push(` ServerName ${h.serverName}`)
}
lines.push(' SSLEngine on')
lines.push(' # mod_md supplies the *.7rnx.net certificate for this host.')
lines.push(' ProxyPreserveHost On')
// Governs the WEBSOCKET path only, now that the ProxyPass below carries its own timeout: a WS
// upgrade goes through the RewriteRule [P] and must be allowed to idle.
lines.push(' ProxyTimeout 3600')
lines.push(' RequestHeader set X-Forwarded-Proto "https"')
lines.push(' RewriteEngine On')
// WebSocket (e.g. com.atproto.sync.subscribeRepos firehose) — must precede ProxyPass.
lines.push(' RewriteCond %{HTTP:Upgrade} =websocket [NC]')
lines.push(` RewriteRule ^/(.*)$ ws://${node.ip}:${h.port}/$1 [P,L]`)
lines.push(` ProxyPass / ${backend}/ timeout=${h.timeout ? Number(h.timeout) : DEFAULT_PROXY_TIMEOUT}`)
lines.push(` ProxyPassReverse / ${backend}/`)
lines.push(` ErrorLog \${APACHE_LOG_DIR}/7rnx-${h.name}-error.log`)
lines.push(` CustomLog \${APACHE_LOG_DIR}/7rnx-${h.name}-access.log combined`)
lines.push('</VirtualHost>')
lines.push('')
}
return lines.join('\n')
}
export function generateConfig (registry) {
const lines = []
const now = new Date().toISOString()
lines.push('# Auto-generated by 7rnx-proxy — DO NOT EDIT MANUALLY')
lines.push(`# Generated: ${now}`)
lines.push('#')
lines.push('# Included from the SSL VirtualHost in 000-default-le-ssl.conf')
lines.push('')
// Build nodeMap keyed by petname for IP lookup
const nodeMap = new Map()
for (const node of registry.nodes) {
nodeMap.set(node.petname, node)
}
// Separate services into multi-backend and single-backend
const multiBackend = []
const singleBackend = []
for (const svc of registry.services) {
if (Array.isArray(svc.backends) && svc.backends.length > 1) {
multiBackend.push(svc)
} else {
singleBackend.push(svc)
}
}
// --- Multi-backend services (mod_proxy_balancer) ---
for (const svc of multiBackend) {
const urlPath = svc.rootPath || `/${svc.name}`
const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
const balancerName = svc.name
assertSafe(balancerName, 'service name')
assertSafe(urlPathClean, 'url path')
// Per-member request timeout (seconds). The old value (5s, failontimeout=On)
// killed slow LARGE transfers — a >5s data stall on e.g. the pear-git
// bootstrap tarball tripped failover → 502 (issue #101). Liveness is handled
// separately by the TCP health checks below (a dead member is marked down in
// ~hcinterval*hcfails = ~6s), so the request timeout can be generous without
// slowing failover. Override per service with `svc.timeout`.
const memberTimeout = svc.timeout ? Number(svc.timeout) : DEFAULT_PROXY_TIMEOUT
lines.push(`# === ${svc.name} (balancer) ===`)
lines.push('')
// <Proxy balancer://name> block with health checks
lines.push(`<Proxy balancer://${balancerName}>`)
let firstActiveIp = null
let firstActivePort = null
const hc = 'hcmethod=TCP hcinterval=3 hcpasses=1 hcfails=2'
for (const backend of svc.backends) {
const node = nodeMap.get(backend.node)
if (!node) continue
assertSafe(node.ip, 'backend ip')
const base = ` BalancerMember http://${node.ip}:${backend.port}`
if (backend.mode === 'standby') {
lines.push(`${base} status=+H ${hc}`)
} else {
lines.push(`${base} ${hc}`)
if (!firstActiveIp) {
firstActiveIp = node.ip
firstActivePort = backend.port
}
}
}
lines.push(` ProxySet timeout=${memberTimeout}`)
lines.push('</Proxy>')
lines.push('')
// <Location> block
lines.push(`<Location ${urlPathClean}>`)
lines.push(` ProxyPass balancer://${balancerName}`)
lines.push(` ProxyPassReverse balancer://${balancerName}`)
lines.push(' ProxyPreserveHost On')
lines.push(' RequestHeader set X-Forwarded-Proto "https"')
lines.push(' RequestHeader set X-Forwarded-Port "443"')
lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`)
lines.push('</Location>')
lines.push('')
// WebSocket rewrite to the first active backend. NOTE: a RewriteRule cannot
// health-check or fail over, so WS traffic is pinned to this one member —
// if it goes down, WS upgrades break until it returns (HTTP still fails over
// via the balancer). Acceptable while WS is non-critical; revisit if needed.
if (firstActiveIp) {
lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]')
lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${firstActiveIp}:${firstActivePort}$1 [P,L]`)
lines.push('')
}
}
// --- Single-backend services (grouped by node, existing format) ---
const byNode = new Map()
for (const svc of singleBackend) {
const petname = svc.node
if (!byNode.has(petname)) byNode.set(petname, [])
byNode.get(petname).push(svc)
}
for (const [petname, services] of byNode) {
if (services.length === 0) continue
const node = nodeMap.get(petname)
if (!node) continue
const hasRootPaths = services.some(s => s.rootPath)
const label = hasRootPaths
? `${petname} (${node.ip}) — root-level paths (legacy)`
: `${petname} (${node.ip})`
lines.push(`# === ${label} ===`)
lines.push('')
assertSafe(node.ip, 'node ip')
for (const svc of services) {
const urlPath = svc.rootPath || `/${petname}/${svc.name}`
const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
assertSafe(urlPathClean, 'url path')
const backend = `http://${node.ip}:${svc.port}`
lines.push(`<Location ${urlPathClean}>`)
lines.push(` ProxyPass ${backend} timeout=${svc.timeout ? Number(svc.timeout) : DEFAULT_PROXY_TIMEOUT}`)
lines.push(` ProxyPassReverse ${backend}`)
lines.push(' ProxyPreserveHost On')
lines.push(' RequestHeader set X-Forwarded-Proto "https"')
lines.push(' RequestHeader set X-Forwarded-Port "443"')
lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`)
lines.push('</Location>')
lines.push('')
// WebSocket rewrite rule
lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]')
lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${node.ip}:${svc.port}$1 [P,L]`)
lines.push('')
}
}
return lines.join('\n')
}