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(), [])
})
})