OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy / lib / config-generator.js

272 lines · javascript ref:
// 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')
}