OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy commits / ca845bf

Add host (vhost) mode — subdomain reverse proxy at the URL root
Path-based `service` mode emits <Location> blocks into the apex-vhost include, which serves everything under https://7rnx.net/<path>/. That can't host an atproto PDS: XRPC lives at /xrpc/* and /.well-known/* at the ROOT of a host, and a path-prefixed serviceEndpoint is dropped by clients resolving /xrpc against the origin. So this adds a distinct `host` mode that proxies a whole subdomain to a backend at the root. - registry: `hosts` collection (name, node, port, serverName FQDN) with validation + removeNode cascade + back-compat load for pre-vhost registries. - config-generator: generateVhosts() emits full <VirtualHost *:443> blocks (ServerName, SSLEngine on — mod_md supplies the *.7rnx.net wildcard cert, ProxyPreserveHost, WebSocket rewrite for the firehose, ProxyPass / → backend). Matches the proven pds-7rnx.conf shape. - deployer: deployVhosts() to /etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf (top-level, so VirtualHost blocks are valid), same validate-then-apply-with-rollback flow. - bin: `7rnx host add|list|remove`; `7rnx deploy` now deploys path-config + vhosts; `--dry-run` shows both. - setup: extra NOPASSWD sudoers rule for the vhosts cp (shares configtest/reload). - Fix test script for node 24 (`node --test test/*.test.js`; bare `test/` now errors). Enables multi-user atproto: one vhost per user subdomain (alice.7rnx.net → alice's PDS). Tests: 9 new (generateVhosts + registry host methods), 32/32 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ca845bfe63a6c408ccc1d1ccc24b2d762409266f
by plankatron <fleeky@gmail.com> · 2026-07-02T17:30:56.000Z

Changed files (7) +290 -5

bin/7rnx +51 -2
……@@ -37,9 +37,14 @@ Service Management:
3737 7rnx service remove <node> <name>
3838 7rnx service status [node] Check which services are actually listening
3939
40Host (vhost) Management — subdomain reverse proxy at the URL root (for atproto PDS etc.):
41 7rnx host add <name> <node> --port <port> --server-name <fqdn>
42 7rnx host list
43 7rnx host remove <name>
44
4045Deployment:
41 7rnx deploy Deploy config to 7rnx.net and reload Apache
42 7rnx deploy --dry-run Show generated config without deploying
46 7rnx deploy Deploy path-config + vhosts to 7rnx.net and reload Apache
47 7rnx deploy --dry-run Show generated config (path + vhosts) without deploying
4348 7rnx status Check Apache status on 7rnx.net
4449
4550Remote:
……@@ -70,6 +75,9 @@ function main () {
7075 case 'service':
7176 handleService(registry, subcommand, args.slice(2))
7277 break
78 case 'host':
79 handleHost(registry, subcommand, args.slice(2))
80 break
7381 case 'deploy':
7482 handleDeploy(registry, args.slice(1))
7583 break
……@@ -225,16 +233,57 @@ function handleService (registry, sub, rest) {
225233 }
226234}
227235
236function handleHost (registry, sub, rest) {
237 switch (sub) {
238 case 'add': {
239 const name = rest[0]
240 const nodePetname = rest[1]
241 const flags = parseFlags(rest.slice(2))
242 if (!name || !nodePetname || !flags.port || !flags['server-name']) {
243 console.error('Usage: 7rnx host add <name> <node> --port <port> --server-name <fqdn>')
244 process.exit(1)
245 }
246 registry.addHost(name, nodePetname, flags.port, flags['server-name'])
247 console.log(`Added host "${name}": https://${flags['server-name']}/ → ${nodePetname}:${flags.port}`)
248 console.log('Run `7rnx deploy` to apply (requires the vhosts sudoers rule from setup-7rnx-server.sh).')
249 break
250 }
251 case 'list': {
252 const hosts = registry.listHosts()
253 if (hosts.length === 0) { console.log('No vhosts registered.'); return }
254 for (const h of hosts) console.log(` ${h.name}\t${h.serverName}\t→ ${h.node}:${h.port}`)
255 break
256 }
257 case 'remove': {
258 const name = rest[0]
259 if (!name) { console.error('Usage: 7rnx host remove <name>'); process.exit(1) }
260 registry.removeHost(name)
261 console.log(`Removed host "${name}"`)
262 break
263 }
264 default:
265 console.error('Usage: 7rnx host <add|list|remove>')
266 process.exit(1)
267 }
268}
269
228270function handleDeploy (registry, rest) {
229271 const flags = parseFlags(rest)
230272 const deployer = new Deployer(registry.data)
273 const hasHosts = (registry.data.hosts || []).length > 0
231274
232275 if (flags['dry-run']) {
276 console.log('# ===== path-config (/etc/apache2/7rnx-proxy.conf) =====')
233277 console.log(deployer.dryRun())
278 if (hasHosts) {
279 console.log('\n# ===== vhosts (/etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf) =====')
280 console.log(deployer.dryRunVhosts())
281 }
234282 return
235283 }
236284
237285 deployer.deploy()
286 if (hasHosts) deployer.deployVhosts()
238287}
239288
240289function handleStatus (registry) {
lib/config-generator.js +50 -0
……@@ -9,6 +9,56 @@ function assertSafe (value, kind) {
99 }
1010}
1111
12// Host-routed vhosts (a distinct mode). Emits full <VirtualHost *:443> blocks that
13// proxy a whole subdomain to a backend at the URL ROOT. This file is deployed to
14// sites-enabled/ (loaded at server/top-level context) — NOT the apex-vhost include,
15// because a <VirtualHost> cannot be nested inside another <VirtualHost>. TLS is supplied
16// automatically by mod_md for any *.7rnx.net ServerName (no cert directives needed).
17export function generateVhosts (registry) {
18 const lines = []
19 lines.push('# Auto-generated by 7rnx-proxy — DO NOT EDIT MANUALLY')
20 lines.push(`# Generated: ${new Date().toISOString()}`)
21 lines.push('#')
22 lines.push('# Host-routed reverse proxies (vhost mode). Loaded at top level via sites-enabled/.')
23 lines.push('# TLS is supplied by mod_md for any *.7rnx.net ServerName.')
24 lines.push('')
25
26 const nodeMap = new Map()
27 for (const node of registry.nodes) nodeMap.set(node.petname, node)
28
29 for (const h of registry.hosts || []) {
30 const node = nodeMap.get(h.node)
31 if (!node) continue
32 assertSafe(node.ip, 'node ip')
33 assertSafe(h.name, 'host name')
34 if (!/^[a-z0-9.-]+$/i.test(h.serverName)) {
35 throw new Error(`unsafe serverName for Apache config: ${JSON.stringify(h.serverName)}`)
36 }
37 const backend = `http://${node.ip}:${h.port}`
38
39 lines.push(`# === ${h.name} → ${h.serverName} → ${node.ip}:${h.port} ===`)
40 lines.push('<VirtualHost *:443>')
41 lines.push(` ServerName ${h.serverName}`)
42 lines.push(' SSLEngine on')
43 lines.push(' # mod_md supplies the *.7rnx.net certificate for this host.')
44 lines.push(' ProxyPreserveHost On')
45 lines.push(' ProxyTimeout 3600')
46 lines.push(' RequestHeader set X-Forwarded-Proto "https"')
47 lines.push(' RewriteEngine On')
48 // WebSocket (e.g. com.atproto.sync.subscribeRepos firehose) — must precede ProxyPass.
49 lines.push(' RewriteCond %{HTTP:Upgrade} =websocket [NC]')
50 lines.push(` RewriteRule ^/(.*)$ ws://${node.ip}:${h.port}/$1 [P,L]`)
51 lines.push(` ProxyPass / ${backend}/`)
52 lines.push(` ProxyPassReverse / ${backend}/`)
53 lines.push(` ErrorLog \${APACHE_LOG_DIR}/7rnx-${h.name}-error.log`)
54 lines.push(` CustomLog \${APACHE_LOG_DIR}/7rnx-${h.name}-access.log combined`)
55 lines.push('</VirtualHost>')
56 lines.push('')
57 }
58
59 return lines.join('\n')
60}
61
1262export function generateConfig (registry) {
1363 const lines = []
1464 const now = new Date().toISOString()
lib/deployer.js +54 -1
……@@ -2,7 +2,13 @@ import { execFileSync } from 'node:child_process'
22import fs from 'node:fs'
33import os from 'node:os'
44import path from 'node:path'
5import { generateConfig } from './config-generator.js'
5import { generateConfig, generateVhosts } from './config-generator.js'
6
7// Host-routed vhosts land in sites-enabled/ (top-level, loaded by apache2.conf's
8// IncludeOptional sites-enabled/*.conf) so <VirtualHost> blocks are valid. The path is
9// fixed to match the NOPASSWD sudoers rule installed by setup-7rnx-server.sh.
10const VHOSTS_REMOTE = '/etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf'
11const VHOSTS_TMP = '/tmp/7rnx-proxy-vhosts.conf'
612
713// POSIX single-quote escaping for values that must be interpolated into a
814// remote shell command (e.g. a caller-supplied config path). Wraps the value
……@@ -85,6 +91,53 @@ export class Deployer {
8591 }
8692 }
8793
94 dryRunVhosts () {
95 return generateVhosts(this.registry)
96 }
97
98 // Deploy host-routed vhosts (vhost mode). Same validate-then-apply-with-rollback flow
99 // as deploy(), but to sites-enabled/ (top-level). Uses only cp / apachectl / systemctl
100 // reload — the fixed VHOSTS_REMOTE path matches the extra NOPASSWD sudoers rule.
101 deployVhosts () {
102 const config = generateVhosts(this.registry)
103 const tmpFile = path.join(os.tmpdir(), '7rnx-proxy-vhosts.conf')
104 fs.writeFileSync(tmpFile, config)
105
106 try {
107 console.log('Uploading vhosts config to 7rnx.net...')
108 this._scp(tmpFile, VHOSTS_TMP)
109
110 console.log('Validating vhosts against the live tree (rollback on failure)...')
111 const remote = [
112 `sudo cp ${VHOSTS_REMOTE} ${VHOSTS_REMOTE}.bak 2>/dev/null || true`,
113 `sudo cp ${VHOSTS_TMP} ${VHOSTS_REMOTE}`,
114 'if sudo apachectl configtest 2>&1; then',
115 ' sudo systemctl reload apache2 && echo __DEPLOY_OK__',
116 'else',
117 ` sudo cp ${VHOSTS_REMOTE}.bak ${VHOSTS_REMOTE} 2>/dev/null || true`,
118 ' echo __DEPLOY_FAILED_ROLLED_BACK__',
119 ' exit 1',
120 'fi'
121 ].join('\n')
122
123 let out
124 try {
125 out = this._ssh(remote)
126 } catch (err) {
127 const detail = (err.stdout || '') + (err.stderr || '') || err.message
128 throw new Error(`vhosts config test failed — rolled back, live config unchanged:\n${detail.trim()}`)
129 }
130
131 console.log(out.trim())
132 if (out.indexOf('__DEPLOY_OK__') === -1) {
133 throw new Error('vhosts deploy did not confirm success (no __DEPLOY_OK__ marker)')
134 }
135 console.log('Vhosts deploy complete.')
136 } finally {
137 fs.unlinkSync(tmpFile)
138 }
139 }
140
88141 status () {
89142 try {
90143 console.log('Apache status:')
lib/registry.js +44 -1
……@@ -10,6 +10,8 @@ const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json')
1010// the point of entry rather than trusting the config generator to catch it.
1111const PETNAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/
1212const PATH_RE = /^\/[A-Za-z0-9/_-]*$/
13// FQDN for host-routed vhosts (ServerName), e.g. p2p.7rnx.net — at least one dot.
14const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i
1315
1416function validIp (ip) {
1517 const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
……@@ -29,7 +31,8 @@ const DEFAULT_REGISTRY = {
2931 configPath: '/etc/apache2/7rnx-proxy.conf'
3032 },
3133 nodes: [],
32 services: []
34 services: [],
35 hosts: []
3336}
3437
3538export class Registry {
……@@ -45,6 +48,7 @@ export class Registry {
4548 return this
4649 }
4750 this.data = JSON.parse(fs.readFileSync(this.configFile, 'utf8'))
51 if (!this.data.hosts) this.data.hosts = [] // back-compat: registries predating vhost mode
4852 return this
4953 }
5054
……@@ -81,6 +85,7 @@ export class Registry {
8185
8286 this.data.nodes.splice(idx, 1)
8387 this.data.services = this.data.services.filter(s => s.node !== petname)
88 this.data.hosts = this.data.hosts.filter(h => h.node !== petname)
8489 this.save()
8590 return this
8691 }
……@@ -131,6 +136,44 @@ export class Registry {
131136 return this
132137 }
133138
139 // --- Host-routed vhosts (a distinct mode from path-based services) ---
140 // A vhost proxies a whole subdomain (ServerName) to a backend at the URL ROOT —
141 // required for atproto PDS hosts, which serve /xrpc/* and /.well-known/* at the root
142 // (a path prefix would be dropped by clients resolving /xrpc against the origin).
143 addHost (name, nodePetname, port, serverName) {
144 const node = this.getNode(nodePetname)
145 if (!node) throw new Error(`Node "${nodePetname}" not found`)
146 if (!name || !port || !serverName) throw new Error('name, port, and serverName are required')
147 if (!PETNAME_RE.test(name)) throw new Error(`invalid host name "${name}" (allowed: letters, digits, "-", "_")`)
148 if (!validPort(port)) throw new Error(`invalid port "${port}"`)
149 if (!HOSTNAME_RE.test(serverName)) throw new Error(`invalid serverName "${serverName}" (must be a FQDN like p2p.7rnx.net)`)
150
151 if (this.data.hosts.find(h => h.name === name)) throw new Error(`Host "${name}" already exists`)
152 const dupServer = this.data.hosts.find(h => h.serverName === serverName)
153 if (dupServer) throw new Error(`serverName "${serverName}" already used by host "${dupServer.name}"`)
154
155 this.data.hosts.push({
156 name,
157 node: nodePetname,
158 port: Number(port),
159 serverName,
160 addedAt: new Date().toISOString()
161 })
162 this.save()
163 return this
164 }
165
166 removeHost (name) {
167 const idx = this.data.hosts.findIndex(h => h.name === name)
168 if (idx === -1) throw new Error(`Host "${name}" not found`)
169 this.data.hosts.splice(idx, 1)
170 this.save()
171 return this
172 }
173
174 listHosts () { return this.data.hosts }
175 getHost (name) { return this.data.hosts.find(h => h.name === name) || null }
176
134177 addBackend (serviceName, nodePetname, port, mode) {
135178 const service = this.data.services.find(s => s.name === serviceName)
136179 if (!service) throw new Error(`Service "${serviceName}" not found`)
package.json +1 -1
……@@ -7,7 +7,7 @@
77 "7rnx": "./bin/7rnx"
88 },
99 "scripts": {
10 "test": "node --test test/"
10 "test": "node --test test/*.test.js"
1111 },
1212 "license": "MIT"
1313}
setup-7rnx-server.sh +2 -0
……@@ -12,6 +12,8 @@ echo "=== Step 1: Sudoers rules ==="
1212sudo tee /etc/sudoers.d/7rnx-proxy > /dev/null << 'EOF'
1313# Allow fleeky to deploy proxy config and reload Apache (no password)
1414fleeky ALL=(root) NOPASSWD: /bin/cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf
15# Host-routed vhosts (vhost mode) — a top-level sites-enabled file (VirtualHost blocks).
16fleeky ALL=(root) NOPASSWD: /bin/cp /tmp/7rnx-proxy-vhosts.conf /etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf
1517fleeky ALL=(root) NOPASSWD: /usr/sbin/apachectl configtest
1618fleeky ALL=(root) NOPASSWD: /bin/systemctl reload apache2
1719EOF
test/vhosts.test.js +88 -0
……@@ -0,0 +1,88 @@
1import { describe, it } from 'node:test'
2import assert from 'node:assert/strict'
3import fs from 'node:fs'
4import os from 'node:os'
5import path from 'node:path'
6import { generateVhosts } from '../lib/config-generator.js'
7import { Registry } from '../lib/registry.js'
8
9describe('generateVhosts (host mode)', () => {
10 const registry = {
11 nodes: [{ petname: 'tabertron', ip: '10.22.0.1' }],
12 hosts: [{ name: 'p2p', node: 'tabertron', port: 3399, serverName: 'p2p.7rnx.net' }]
13 }
14
15 it('emits a full VirtualHost that proxies the subdomain at the URL root', () => {
16 const out = generateVhosts(registry)
17 assert.ok(out.includes('<VirtualHost *:443>'), 'VirtualHost block')
18 assert.ok(out.includes('ServerName p2p.7rnx.net'), 'ServerName')
19 assert.ok(out.includes('SSLEngine on'), 'SSL (mod_md wildcard)')
20 assert.ok(out.includes('ProxyPass / http://10.22.0.1:3399/'), 'root ProxyPass to backend')
21 assert.ok(out.includes('ProxyPassReverse / http://10.22.0.1:3399/'), 'ProxyPassReverse')
22 assert.ok(out.includes('ws://10.22.0.1:3399/$1'), 'WebSocket rewrite (firehose)')
23 assert.ok(out.includes('</VirtualHost>'), 'closes the block')
24 })
25
26 it('does NOT emit a path-prefixed <Location> (that would break atproto /xrpc)', () => {
27 const out = generateVhosts(registry)
28 assert.ok(!out.includes('<Location'), 'no Location blocks in vhost mode')
29 assert.ok(!out.includes('X-Forwarded-Prefix'), 'no path prefix')
30 })
31
32 it('skips a host whose node is missing', () => {
33 const out = generateVhosts({ nodes: [], hosts: registry.hosts })
34 assert.ok(!out.includes('<VirtualHost'), 'no vhost without a resolvable node')
35 })
36
37 it('rejects an unsafe serverName', () => {
38 assert.throws(() => generateVhosts({
39 nodes: [{ petname: 'x', ip: '10.0.0.1' }],
40 hosts: [{ name: 'bad', node: 'x', port: 80, serverName: 'evil.com\n<script>' }]
41 }), /unsafe serverName/)
42 })
43})
44
45describe('Registry host methods', () => {
46 function tmpRegistry () {
47 const dir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-reg-'))
48 const r = new Registry(dir).load()
49 r.addNode('tabertron', '10.22.0.1')
50 return r
51 }
52
53 it('addHost validates and stores a vhost', () => {
54 const r = tmpRegistry()
55 r.addHost('p2p', 'tabertron', 3399, 'p2p.7rnx.net')
56 assert.equal(r.listHosts().length, 1)
57 assert.equal(r.getHost('p2p').serverName, 'p2p.7rnx.net')
58 assert.equal(r.getHost('p2p').port, 3399)
59 })
60
61 it('rejects a non-FQDN serverName, bad port, and unknown node', () => {
62 const r = tmpRegistry()
63 assert.throws(() => r.addHost('a', 'tabertron', 3399, 'notafqdn'), /FQDN/)
64 assert.throws(() => r.addHost('b', 'tabertron', 99999, 'p2p.7rnx.net'), /invalid port/)
65 assert.throws(() => r.addHost('c', 'nope', 3399, 'p2p.7rnx.net'), /not found/)
66 })
67
68 it('rejects duplicate host name and duplicate serverName', () => {
69 const r = tmpRegistry()
70 r.addHost('p2p', 'tabertron', 3399, 'p2p.7rnx.net')
71 assert.throws(() => r.addHost('p2p', 'tabertron', 4000, 'other.7rnx.net'), /already exists/)
72 assert.throws(() => r.addHost('p2p2', 'tabertron', 4000, 'p2p.7rnx.net'), /already used/)
73 })
74
75 it('removeNode cascades to its hosts', () => {
76 const r = tmpRegistry()
77 r.addHost('p2p', 'tabertron', 3399, 'p2p.7rnx.net')
78 r.removeNode('tabertron')
79 assert.equal(r.listHosts().length, 0)
80 })
81
82 it('back-compat: a registry file without a hosts array loads cleanly', () => {
83 const dir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-reg-'))
84 fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify({ server: {}, nodes: [], services: [] }))
85 const r = new Registry(dir).load()
86 assert.deepEqual(r.listHosts(), [])
87 })
88})