OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy / test / vhosts.test.js

131 lines · javascript ref:
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { generateVhosts } from '../lib/config-generator.js'
import { Registry } from '../lib/registry.js'

describe('generateVhosts (host mode)', () => {
  const registry = {
    nodes: [{ petname: 'tabertron', ip: '10.22.0.1' }],
    hosts: [{ name: 'p2p', node: 'tabertron', port: 3399, serverName: 'p2p.7rnx.net' }]
  }

  it('emits a full VirtualHost that proxies the subdomain at the URL root', () => {
    const out = generateVhosts(registry)
    assert.ok(out.includes('<VirtualHost *:443>'), 'VirtualHost block')
    assert.ok(out.includes('ServerName p2p.7rnx.net'), 'ServerName')
    assert.ok(out.includes('SSLEngine on'), 'SSL (mod_md wildcard)')
    assert.ok(out.includes('ProxyPass / http://10.22.0.1:3399/'), 'root ProxyPass to backend')
    assert.ok(out.includes('ProxyPassReverse / http://10.22.0.1:3399/'), 'ProxyPassReverse')
    assert.ok(out.includes('ws://10.22.0.1:3399/$1'), 'WebSocket rewrite (firehose)')
    assert.ok(out.includes('</VirtualHost>'), 'closes the block')
  })

  it('does NOT emit a path-prefixed <Location> (that would break atproto /xrpc)', () => {
    const out = generateVhosts(registry)
    assert.ok(!out.includes('<Location'), 'no Location blocks in vhost mode')
    assert.ok(!out.includes('X-Forwarded-Prefix'), 'no path prefix')
  })

  it('skips a host whose node is missing', () => {
    const out = generateVhosts({ nodes: [], hosts: registry.hosts })
    assert.ok(!out.includes('<VirtualHost'), 'no vhost without a resolvable node')
  })

  it('rejects an unsafe serverName', () => {
    assert.throws(() => generateVhosts({
      nodes: [{ petname: 'x', ip: '10.0.0.1' }],
      hosts: [{ name: 'bad', node: 'x', port: 80, serverName: 'evil.com\n<script>' }]
    }), /unsafe serverName/)
  })

  // Every client of this proxy is remote (~95ms RTT to the VPS, ~110ms more over
  // the VPN to a home backend), so a TCP+TLS handshake costs ~210ms — measured half
  // of a cold page load. Debian ships KeepAliveTimeout 5, which drops the connection
  // between one page view and the next click, making the user re-pay that handshake
  // and read as "it randomly needs to warm up again".
  it('emits a KeepAliveTimeout override at SERVER context', () => {
    const out = generateVhosts(registry)
    assert.match(out, /^KeepAliveTimeout 65$/m, 'KeepAliveTimeout emitted')

    // Must sit ABOVE the first <VirtualHost>: nested inside one it would apply to
    // that vhost alone, leaving the apex site (which serves /git) on the default 5.
    const ka = out.search(/^KeepAliveTimeout 65$/m)
    const firstVhost = out.indexOf('<VirtualHost')
    assert.ok(ka !== -1 && firstVhost !== -1, 'both directives present')
    assert.ok(ka < firstVhost, 'KeepAliveTimeout precedes the first VirtualHost block')
  })

  // It is global tuning, not per-host, so it must not depend on registry contents.
  it('emits the KeepAliveTimeout override even with no hosts registered', () => {
    const out = generateVhosts({ nodes: [], hosts: [] })
    assert.match(out, /^KeepAliveTimeout 65$/m)
  })

  // h2 multiplexes every request for a page over ONE connection, so the ~210ms
  // handshake is paid once instead of per parallel connection. `Protocols` is a
  // CORE directive (2.4.17+), so this is valid even before mod_http2 is enabled
  // — an unavailable protocol is simply never negotiated, and clients stay on
  // http/1.1. That ordering matters: the config can ship before the module.
  it('offers h2 ahead of http/1.1 at SERVER context', () => {
    const out = generateVhosts(registry)
    assert.match(out, /^Protocols h2 http\/1\.1$/m, 'Protocols emitted')

    const proto = out.search(/^Protocols h2 http\/1\.1$/m)
    const firstVhost = out.indexOf('<VirtualHost')
    assert.ok(proto < firstVhost, 'precedes the first VirtualHost so the apex site inherits it')
  })

  it('emits Protocols even with no hosts registered', () => {
    const out = generateVhosts({ nodes: [], hosts: [] })
    assert.match(out, /^Protocols h2 http\/1\.1$/m)
  })
})

describe('Registry host methods', () => {
  function tmpRegistry () {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-reg-'))
    const r = new Registry(dir).load()
    r.addNode('tabertron', '10.22.0.1')
    return r
  }

  it('addHost validates and stores a vhost', () => {
    const r = tmpRegistry()
    r.addHost('p2p', 'tabertron', 3399, 'p2p.7rnx.net')
    assert.equal(r.listHosts().length, 1)
    assert.equal(r.getHost('p2p').serverName, 'p2p.7rnx.net')
    assert.equal(r.getHost('p2p').port, 3399)
  })

  it('rejects a non-FQDN serverName, bad port, and unknown node', () => {
    const r = tmpRegistry()
    assert.throws(() => r.addHost('a', 'tabertron', 3399, 'notafqdn'), /FQDN/)
    assert.throws(() => r.addHost('b', 'tabertron', 99999, 'p2p.7rnx.net'), /invalid port/)
    assert.throws(() => r.addHost('c', 'nope', 3399, 'p2p.7rnx.net'), /not found/)
  })

  it('rejects duplicate host name and duplicate serverName', () => {
    const r = tmpRegistry()
    r.addHost('p2p', 'tabertron', 3399, 'p2p.7rnx.net')
    assert.throws(() => r.addHost('p2p', 'tabertron', 4000, 'other.7rnx.net'), /already exists/)
    assert.throws(() => r.addHost('p2p2', 'tabertron', 4000, 'p2p.7rnx.net'), /already used/)
  })

  it('removeNode cascades to its hosts', () => {
    const r = tmpRegistry()
    r.addHost('p2p', 'tabertron', 3399, 'p2p.7rnx.net')
    r.removeNode('tabertron')
    assert.equal(r.listHosts().length, 0)
  })

  it('back-compat: a registry file without a hosts array loads cleanly', () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-reg-'))
    fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify({ server: {}, nodes: [], services: [] }))
    const r = new Registry(dir).load()
    assert.deepEqual(r.listHosts(), [])
  })
})