| … | … | @@ -37,9 +37,14 @@ Service Management: |
| 37 | 37 | 7rnx service remove <node> <name> |
| 38 | 38 | 7rnx service status [node] Check which services are actually listening |
| 39 | 39 | |
| 40 | Host (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 | ||
| 40 | 45 | Deployment: |
| 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 | |
| 43 | 48 | 7rnx status Check Apache status on 7rnx.net |
| 44 | 49 | |
| 45 | 50 | Remote: |
| … | … | @@ -70,6 +75,9 @@ function main () { |
| 70 | 75 | case 'service': |
| 71 | 76 | handleService(registry, subcommand, args.slice(2)) |
| 72 | 77 | break |
| 78 | case 'host': | |
| 79 | handleHost(registry, subcommand, args.slice(2)) | |
| 80 | break | |
| 73 | 81 | case 'deploy': |
| 74 | 82 | handleDeploy(registry, args.slice(1)) |
| 75 | 83 | break |
| … | … | @@ -225,16 +233,57 @@ function handleService (registry, sub, rest) { |
| 225 | 233 | } |
| 226 | 234 | } |
| 227 | 235 | |
| 236 | function 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 | ||
| 228 | 270 | function handleDeploy (registry, rest) { |
| 229 | 271 | const flags = parseFlags(rest) |
| 230 | 272 | const deployer = new Deployer(registry.data) |
| 273 | const hasHosts = (registry.data.hosts || []).length > 0 | |
| 231 | 274 | |
| 232 | 275 | if (flags['dry-run']) { |
| 276 | console.log('# ===== path-config (/etc/apache2/7rnx-proxy.conf) =====') | |
| 233 | 277 | 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 | } | |
| 234 | 282 | return |
| 235 | 283 | } |
| 236 | 284 | |
| 237 | 285 | deployer.deploy() |
| 286 | if (hasHosts) deployer.deployVhosts() | |
| 238 | 287 | } |
| 239 | 288 | |
| 240 | 289 | function handleStatus (registry) { |
| … | … | @@ -9,6 +9,56 @@ function assertSafe (value, kind) { |
| 9 | 9 | } |
| 10 | 10 | } |
| 11 | 11 | |
| 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). | |
| 17 | export 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 | ||
| 12 | 62 | export function generateConfig (registry) { |
| 13 | 63 | const lines = [] |
| 14 | 64 | const now = new Date().toISOString() |
| … | … | @@ -2,7 +2,13 @@ import { execFileSync } from 'node:child_process' |
| 2 | 2 | import fs from 'node:fs' |
| 3 | 3 | import os from 'node:os' |
| 4 | 4 | import path from 'node:path' |
| 5 | import { generateConfig } from './config-generator.js' | |
| 5 | import { 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. | |
| 10 | const VHOSTS_REMOTE = '/etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf' | |
| 11 | const VHOSTS_TMP = '/tmp/7rnx-proxy-vhosts.conf' | |
| 6 | 12 | |
| 7 | 13 | // POSIX single-quote escaping for values that must be interpolated into a |
| 8 | 14 | // remote shell command (e.g. a caller-supplied config path). Wraps the value |
| … | … | @@ -85,6 +91,53 @@ export class Deployer { |
| 85 | 91 | } |
| 86 | 92 | } |
| 87 | 93 | |
| 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 | ||
| 88 | 141 | status () { |
| 89 | 142 | try { |
| 90 | 143 | console.log('Apache status:') |
| … | … | @@ -10,6 +10,8 @@ const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json') |
| 10 | 10 | // the point of entry rather than trusting the config generator to catch it. |
| 11 | 11 | const PETNAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ |
| 12 | 12 | const PATH_RE = /^\/[A-Za-z0-9/_-]*$/ |
| 13 | // FQDN for host-routed vhosts (ServerName), e.g. p2p.7rnx.net — at least one dot. | |
| 14 | const 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 | |
| 13 | 15 | |
| 14 | 16 | function validIp (ip) { |
| 15 | 17 | const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip) |
| … | … | @@ -29,7 +31,8 @@ const DEFAULT_REGISTRY = { |
| 29 | 31 | configPath: '/etc/apache2/7rnx-proxy.conf' |
| 30 | 32 | }, |
| 31 | 33 | nodes: [], |
| 32 | services: [] | |
| 34 | services: [], | |
| 35 | hosts: [] | |
| 33 | 36 | } |
| 34 | 37 | |
| 35 | 38 | export class Registry { |
| … | … | @@ -45,6 +48,7 @@ export class Registry { |
| 45 | 48 | return this |
| 46 | 49 | } |
| 47 | 50 | this.data = JSON.parse(fs.readFileSync(this.configFile, 'utf8')) |
| 51 | if (!this.data.hosts) this.data.hosts = [] // back-compat: registries predating vhost mode | |
| 48 | 52 | return this |
| 49 | 53 | } |
| 50 | 54 | |
| … | … | @@ -81,6 +85,7 @@ export class Registry { |
| 81 | 85 | |
| 82 | 86 | this.data.nodes.splice(idx, 1) |
| 83 | 87 | this.data.services = this.data.services.filter(s => s.node !== petname) |
| 88 | this.data.hosts = this.data.hosts.filter(h => h.node !== petname) | |
| 84 | 89 | this.save() |
| 85 | 90 | return this |
| 86 | 91 | } |
| … | … | @@ -131,6 +136,44 @@ export class Registry { |
| 131 | 136 | return this |
| 132 | 137 | } |
| 133 | 138 | |
| 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 | ||
| 134 | 177 | addBackend (serviceName, nodePetname, port, mode) { |
| 135 | 178 | const service = this.data.services.find(s => s.name === serviceName) |
| 136 | 179 | if (!service) throw new Error(`Service "${serviceName}" not found`) |
| … | … | @@ -7,7 +7,7 @@ |
| 7 | 7 | "7rnx": "./bin/7rnx" |
| 8 | 8 | }, |
| 9 | 9 | "scripts": { |
| 10 | "test": "node --test test/" | |
| 10 | "test": "node --test test/*.test.js" | |
| 11 | 11 | }, |
| 12 | 12 | "license": "MIT" |
| 13 | 13 | } |
| … | … | @@ -12,6 +12,8 @@ echo "=== Step 1: Sudoers rules ===" |
| 12 | 12 | sudo tee /etc/sudoers.d/7rnx-proxy > /dev/null << 'EOF' |
| 13 | 13 | # Allow fleeky to deploy proxy config and reload Apache (no password) |
| 14 | 14 | fleeky 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). | |
| 16 | fleeky ALL=(root) NOPASSWD: /bin/cp /tmp/7rnx-proxy-vhosts.conf /etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf | |
| 15 | 17 | fleeky ALL=(root) NOPASSWD: /usr/sbin/apachectl configtest |
| 16 | 18 | fleeky ALL=(root) NOPASSWD: /bin/systemctl reload apache2 |
| 17 | 19 | EOF |
| … | … | @@ -0,0 +1,88 @@ |
| 1 | import { describe, it } from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import fs from 'node:fs' | |
| 4 | import os from 'node:os' | |
| 5 | import path from 'node:path' | |
| 6 | import { generateVhosts } from '../lib/config-generator.js' | |
| 7 | import { Registry } from '../lib/registry.js' | |
| 8 | ||
| 9 | describe('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 | ||
| 45 | describe('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 | }) |