OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy commits / d537cbb

Add multi-backend / load-balancer support + tests
- bin/7rnx: new subcommands `service add-backend` and `remove-backend` for attaching multiple peers to one service. List output renders balancer view when a service has >1 backend. - lib/registry.js: backends field on Service records (~90 LOC of registry support: addBackend, removeBackend, validation). - lib/config-generator.js: emit Apache balancer-manager config when a service has multiple backends (~80 LOC). - lib/deployer.js: minor tweak to deployment flow. - test/config-generator.test.js + test/registry.test.js: new test files covering the balancer logic. Was sitting uncommitted on disk in this repo. Committing the in-progress work as a coherent slice so future changes start from a clean tree. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
d537cbb12d166bcf4fb28d2dbdcfd9614315694e
by plankatron <fleeky@gmail.com> · 2026-04-15T21:32:34.000Z

Changed files (6) +425 -16

bin/7rnx +33 -2
……@@ -31,6 +31,8 @@ Node Management:
3131
3232Service Management:
3333 7rnx service add <node> <name> --port <port> [--prefix <path>] [--root-path <path>]
34 7rnx service add-backend <service> --node <petname> --port <port> [--mode active|standby]
35 7rnx service remove-backend <service> --node <petname>
3436 7rnx service list [node]
3537 7rnx service remove <node> <name>
3638 7rnx service status [node] Check which services are actually listening
……@@ -156,7 +158,14 @@ function handleService (registry, sub, rest) {
156158 }
157159 for (const s of services) {
158160 const urlPath = s.rootPath || `/${s.node}/${s.name}/`
159 console.log(` ${s.node}/${s.name}\t:${s.port}\t→ ${urlPath}`)
161 if (s.backends && s.backends.length > 1) {
162 console.log(` ${s.name}\t→ ${urlPath} (balancer)`)
163 for (const b of s.backends) {
164 console.log(` ${b.node}:${b.port}\t${b.mode}`)
165 }
166 } else {
167 console.log(` ${s.node}/${s.name}\t:${s.port}\t→ ${urlPath}`)
168 }
160169 }
161170 break
162171 }
……@@ -171,6 +180,28 @@ function handleService (registry, sub, rest) {
171180 console.log(`Removed service "${serviceName}" from "${nodePetname}"`)
172181 break
173182 }
183 case 'add-backend': {
184 const serviceName = rest[0]
185 const flags = parseFlags(rest.slice(1))
186 if (!serviceName || !flags.node || !flags.port) {
187 console.error('Usage: 7rnx service add-backend <service> --node <petname> --port <port> [--mode active|standby]')
188 process.exit(1)
189 }
190 registry.addBackend(serviceName, flags.node, flags.port, flags.mode || 'standby')
191 console.log(`Added backend: ${flags.node}:${flags.port} (${flags.mode || 'standby'}) to service "${serviceName}"`)
192 break
193 }
194 case 'remove-backend': {
195 const serviceName = rest[0]
196 const flags = parseFlags(rest.slice(1))
197 if (!serviceName || !flags.node) {
198 console.error('Usage: 7rnx service remove-backend <service> --node <petname>')
199 process.exit(1)
200 }
201 registry.removeBackend(serviceName, flags.node)
202 console.log(`Removed backend "${flags.node}" from service "${serviceName}"`)
203 break
204 }
174205 case 'status': {
175206 const nodePetname = rest[0]
176207 const deployer = new Deployer(registry.data)
……@@ -189,7 +220,7 @@ function handleService (registry, sub, rest) {
189220 break
190221 }
191222 default:
192 console.error('Usage: 7rnx service <add|list|remove|status>')
223 console.error('Usage: 7rnx service <add|list|remove|add-backend|remove-backend|status>')
193224 process.exit(1)
194225 }
195226}
lib/config-generator.js +78 -8
……@@ -8,17 +8,87 @@ export function generateConfig (registry) {
88 lines.push('# Included from the SSL VirtualHost in 000-default-le-ssl.conf')
99 lines.push('')
1010
11 // Group services by node
11 // Build nodeMap keyed by petname for IP lookup
1212 const nodeMap = new Map()
1313 for (const node of registry.nodes) {
14 nodeMap.set(node.petname, {
15 node,
16 services: registry.services.filter(s => s.node === node.petname)
17 })
14 nodeMap.set(node.petname, node)
1815 }
1916
20 for (const [petname, { node, services }] of nodeMap) {
17 // Separate services into multi-backend and single-backend
18 const multiBackend = []
19 const singleBackend = []
20
21 for (const svc of registry.services) {
22 if (Array.isArray(svc.backends) && svc.backends.length > 1) {
23 multiBackend.push(svc)
24 } else {
25 singleBackend.push(svc)
26 }
27 }
28
29 // --- Multi-backend services (mod_proxy_balancer) ---
30 for (const svc of multiBackend) {
31 const urlPath = svc.rootPath || `/${svc.name}`
32 const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
33 const balancerName = svc.name
34
35 lines.push(`# === ${svc.name} (balancer) ===`)
36 lines.push('')
37
38 // <Proxy balancer://name> block with health checks
39 lines.push(`<Proxy balancer://${balancerName}>`)
40 let firstActiveIp = null
41 let firstActivePort = null
42 const hc = 'hcmethod=TCP hcinterval=3 hcpasses=1 hcfails=2'
43 for (const backend of svc.backends) {
44 const node = nodeMap.get(backend.node)
45 if (!node) continue
46 const base = ` BalancerMember http://${node.ip}:${backend.port}`
47 if (backend.mode === 'standby') {
48 lines.push(`${base} status=+H ${hc}`)
49 } else {
50 lines.push(`${base} ${hc}`)
51 if (!firstActiveIp) {
52 firstActiveIp = node.ip
53 firstActivePort = backend.port
54 }
55 }
56 }
57 lines.push(' ProxySet failontimeout=On timeout=5')
58 lines.push('</Proxy>')
59 lines.push('')
60
61 // <Location> block
62 lines.push(`<Location ${urlPathClean}>`)
63 lines.push(` ProxyPass balancer://${balancerName}`)
64 lines.push(` ProxyPassReverse balancer://${balancerName}`)
65 lines.push(' ProxyPreserveHost On')
66 lines.push(' RequestHeader set X-Forwarded-Proto "https"')
67 lines.push(' RequestHeader set X-Forwarded-Port "443"')
68 lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`)
69 lines.push('</Location>')
70 lines.push('')
71
72 // WebSocket rewrite to first active backend
73 if (firstActiveIp) {
74 lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]')
75 lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${firstActiveIp}:${firstActivePort}$1 [P,L]`)
76 lines.push('')
77 }
78 }
79
80 // --- Single-backend services (grouped by node, existing format) ---
81 const byNode = new Map()
82 for (const svc of singleBackend) {
83 const petname = svc.node
84 if (!byNode.has(petname)) byNode.set(petname, [])
85 byNode.get(petname).push(svc)
86 }
87
88 for (const [petname, services] of byNode) {
2189 if (services.length === 0) continue
90 const node = nodeMap.get(petname)
91 if (!node) continue
2292
2393 const hasRootPaths = services.some(s => s.rootPath)
2494 const label = hasRootPaths
……@@ -36,7 +106,7 @@ export function generateConfig (registry) {
36106 lines.push(`<Location ${urlPathClean}>`)
37107 lines.push(` ProxyPass ${backend}`)
38108 lines.push(` ProxyPassReverse ${backend}`)
39 lines.push(' ProxyPreserveHost Off')
109 lines.push(' ProxyPreserveHost On')
40110 lines.push(' RequestHeader set X-Forwarded-Proto "https"')
41111 lines.push(' RequestHeader set X-Forwarded-Port "443"')
42112 lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`)
……@@ -44,7 +114,7 @@ export function generateConfig (registry) {
44114 lines.push('')
45115
46116 // WebSocket rewrite rule
47 lines.push(`RewriteCond %{HTTP:Upgrade} =websocket [NC]`)
117 lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]')
48118 lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${node.ip}:${svc.port}$1 [P,L]`)
49119 lines.push('')
50120 }
lib/deployer.js +1 -1
……@@ -101,7 +101,7 @@ export class Deployer {
101101 // tabertron is local — run directly
102102 output = execSync(`bash -c '${cmd}'`, { encoding: 'utf8', timeout: 10000 })
103103 } else {
104 output = execSync(`ssh ${node.petname} "${cmd}"`, { encoding: 'utf8', timeout: 10000 })
104 output = execSync(`ssh ${node.petname}.local '${cmd}'`, { encoding: 'utf8', timeout: 10000 })
105105 }
106106 } catch {
107107 // Node unreachable
lib/registry.js +87 -5
……@@ -16,22 +16,24 @@ const DEFAULT_REGISTRY = {
1616}
1717
1818export class Registry {
19 constructor () {
19 constructor (configDir) {
20 this.configDir = configDir || CONFIG_DIR
21 this.configFile = path.join(this.configDir, 'registry.json')
2022 this.data = null
2123 }
2224
2325 load () {
24 if (!fs.existsSync(REGISTRY_PATH)) {
26 if (!fs.existsSync(this.configFile)) {
2527 this.data = structuredClone(DEFAULT_REGISTRY)
2628 return this
2729 }
28 this.data = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'))
30 this.data = JSON.parse(fs.readFileSync(this.configFile, 'utf8'))
2931 return this
3032 }
3133
3234 save () {
33 fs.mkdirSync(CONFIG_DIR, { recursive: true })
34 fs.writeFileSync(REGISTRY_PATH, JSON.stringify(this.data, null, 2) + '\n')
35 fs.mkdirSync(this.configDir, { recursive: true })
36 fs.writeFileSync(this.configFile, JSON.stringify(this.data, null, 2) + '\n')
3537 return this
3638 }
3739
……@@ -106,6 +108,86 @@ export class Registry {
106108 return this
107109 }
108110
111 addBackend (serviceName, nodePetname, port, mode) {
112 const service = this.data.services.find(s => s.name === serviceName)
113 if (!service) throw new Error(`Service "${serviceName}" not found`)
114
115 const node = this.getNode(nodePetname)
116 if (!node) throw new Error(`Node "${nodePetname}" not found`)
117
118 if (!service.backends) {
119 service.backends = [{
120 node: service.node,
121 port: service.port,
122 mode: 'active'
123 }]
124 }
125
126 const duplicate = service.backends.find(b => b.node === nodePetname)
127 if (duplicate) throw new Error(`Backend for node "${nodePetname}" already exists on service "${serviceName}"`)
128
129 service.backends.push({
130 node: nodePetname,
131 port: Number(port),
132 mode: mode || 'active'
133 })
134 this.save()
135 return this
136 }
137
138 removeBackend (serviceName, nodePetname) {
139 const service = this.data.services.find(s => s.name === serviceName)
140 if (!service) throw new Error(`Service "${serviceName}" not found`)
141
142 if (!service.backends) throw new Error(`Service "${serviceName}" has no backends`)
143
144 const idx = service.backends.findIndex(b => b.node === nodePetname)
145 if (idx === -1) throw new Error(`Backend for node "${nodePetname}" not found on service "${serviceName}"`)
146
147 if (service.backends.length <= 1) throw new Error(`Cannot remove last backend from service "${serviceName}"`)
148
149 service.backends.splice(idx, 1)
150
151 if (service.backends.length === 1) {
152 const remaining = service.backends[0]
153 service.node = remaining.node
154 service.port = remaining.port
155 delete service.backends
156 }
157
158 this.save()
159 return this
160 }
161
162 getServiceWithBackends (serviceName) {
163 const service = this.data.services.find(s => s.name === serviceName)
164 if (!service) throw new Error(`Service "${serviceName}" not found`)
165
166 const result = { ...service }
167
168 if (service.backends) {
169 result.resolvedBackends = service.backends.map(b => {
170 const node = this.getNode(b.node)
171 return {
172 node: b.node,
173 ip: node ? node.ip : null,
174 port: b.port,
175 mode: b.mode
176 }
177 })
178 } else {
179 const node = this.getNode(service.node)
180 result.resolvedBackends = [{
181 node: service.node,
182 ip: node ? node.ip : null,
183 port: service.port,
184 mode: 'active'
185 }]
186 }
187
188 return result
189 }
190
109191 listServices (nodePetname) {
110192 if (nodePetname) {
111193 return this.data.services.filter(s => s.node === nodePetname)
test/config-generator.test.js +108 -0
……@@ -0,0 +1,108 @@
1import { describe, it } from 'node:test'
2import assert from 'node:assert/strict'
3import { generateConfig } from '../lib/config-generator.js'
4
5describe('generateConfig', () => {
6 it('generates single-backend config (backwards compat)', () => {
7 const registry = {
8 nodes: [{ petname: 'tabertron', ip: '10.22.0.1' }],
9 services: [
10 { node: 'tabertron', name: 'viz', port: 7890, rootPath: '/viz' }
11 ]
12 }
13
14 const output = generateConfig(registry)
15
16 assert.ok(output.includes('ProxyPass http://10.22.0.1:7890'), 'should have direct ProxyPass')
17 assert.ok(output.includes('ProxyPassReverse http://10.22.0.1:7890'), 'should have ProxyPassReverse')
18 assert.ok(output.includes('<Location /viz>'), 'should have Location block')
19 assert.ok(output.includes('ws://10.22.0.1:7890'), 'should have WebSocket rewrite')
20 assert.ok(!output.includes('balancer://'), 'should NOT have balancer directives')
21 })
22
23 it('generates balancer config for multi-backend', () => {
24 const registry = {
25 nodes: [
26 { petname: 'tabertron', ip: '10.22.0.1' },
27 { petname: 'stortron', ip: '10.22.0.2' }
28 ],
29 services: [
30 {
31 name: 'pear-git',
32 rootPath: '/git',
33 backends: [
34 { node: 'tabertron', port: 5005, mode: 'active' },
35 { node: 'stortron', port: 5005, mode: 'standby' }
36 ]
37 }
38 ]
39 }
40
41 const output = generateConfig(registry)
42
43 assert.ok(output.includes('<Proxy balancer://pear-git>'), 'should have Proxy balancer block')
44 assert.ok(output.includes('BalancerMember http://10.22.0.1:5005'), 'should have active member')
45 assert.ok(output.includes('BalancerMember http://10.22.0.2:5005 status=+H'), 'standby should have status=+H')
46 assert.ok(output.includes('ProxySet failontimeout=On timeout=30'), 'should have ProxySet')
47 assert.ok(output.includes('<Location /git>'), 'should have Location block')
48 assert.ok(output.includes('ProxyPass balancer://pear-git'), 'Location should proxy to balancer')
49 assert.ok(output.includes('ProxyPassReverse balancer://pear-git'), 'should have ProxyPassReverse for balancer')
50 assert.ok(output.includes('ws://10.22.0.1:5005'), 'WebSocket should point to active backend')
51 })
52
53 it('mixes single and multi-backend', () => {
54 const registry = {
55 nodes: [
56 { petname: 'tabertron', ip: '10.22.0.1' },
57 { petname: 'stortron', ip: '10.22.0.2' }
58 ],
59 services: [
60 { node: 'tabertron', name: 'viz', port: 7890, rootPath: '/viz' },
61 {
62 name: 'pear-git',
63 rootPath: '/git',
64 backends: [
65 { node: 'tabertron', port: 5005, mode: 'active' },
66 { node: 'stortron', port: 5005, mode: 'standby' }
67 ]
68 }
69 ]
70 }
71
72 const output = generateConfig(registry)
73
74 // Multi-backend present
75 assert.ok(output.includes('<Proxy balancer://pear-git>'), 'should have balancer block')
76 assert.ok(output.includes('BalancerMember http://10.22.0.1:5005'), 'should have active member')
77
78 // Single-backend present
79 assert.ok(output.includes('ProxyPass http://10.22.0.1:7890'), 'should have direct ProxyPass for single')
80 assert.ok(output.includes('<Location /viz>'), 'should have Location for single backend')
81 })
82
83 it('active-active mode has no status=+H', () => {
84 const registry = {
85 nodes: [
86 { petname: 'tabertron', ip: '10.22.0.1' },
87 { petname: 'stortron', ip: '10.22.0.2' }
88 ],
89 services: [
90 {
91 name: 'api',
92 rootPath: '/api',
93 backends: [
94 { node: 'tabertron', port: 8080, mode: 'active' },
95 { node: 'stortron', port: 8080, mode: 'active' }
96 ]
97 }
98 ]
99 }
100
101 const output = generateConfig(registry)
102
103 assert.ok(output.includes('<Proxy balancer://api>'), 'should have balancer block')
104 assert.ok(output.includes('BalancerMember http://10.22.0.1:8080'), 'should have first member')
105 assert.ok(output.includes('BalancerMember http://10.22.0.2:8080'), 'should have second member')
106 assert.ok(!output.includes('status=+H'), 'should NOT have status=+H for active-active')
107 })
108})
test/registry.test.js +118 -0
……@@ -0,0 +1,118 @@
1import { describe, it } from 'node:test'
2import assert from 'node:assert/strict'
3import fs from 'node:fs'
4import path from 'node:path'
5import os from 'node:os'
6import { Registry } from '../lib/registry.js'
7
8function createTestRegistry () {
9 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-test-'))
10 const reg = new Registry(tmpDir)
11 reg.load()
12 return { reg, tmpDir }
13}
14
15describe('Registry multi-backend support', () => {
16 it('addService still works with single node (backwards compat)', () => {
17 const { reg } = createTestRegistry()
18 reg.addNode('node1', '10.0.0.1')
19 reg.addService('node1', 'web', 8080)
20
21 const services = reg.listServices()
22 assert.equal(services.length, 1)
23 assert.equal(services[0].name, 'web')
24 assert.equal(services[0].node, 'node1')
25 assert.equal(services[0].port, 8080)
26 assert.equal(services[0].backends, undefined)
27 })
28
29 it('addBackend adds a second backend', () => {
30 const { reg } = createTestRegistry()
31 reg.addNode('node1', '10.0.0.1')
32 reg.addNode('node2', '10.0.0.2')
33 reg.addService('node1', 'web', 8080)
34
35 reg.addBackend('web', 'node2', 9090, 'standby')
36
37 const services = reg.listServices()
38 const svc = services.find(s => s.name === 'web')
39 assert.ok(svc.backends)
40 assert.equal(svc.backends.length, 2)
41 assert.equal(svc.backends[0].node, 'node1')
42 assert.equal(svc.backends[0].port, 8080)
43 assert.equal(svc.backends[0].mode, 'active')
44 assert.equal(svc.backends[1].node, 'node2')
45 assert.equal(svc.backends[1].port, 9090)
46 assert.equal(svc.backends[1].mode, 'standby')
47 })
48
49 it('addBackend throws if service does not exist', () => {
50 const { reg } = createTestRegistry()
51 reg.addNode('node1', '10.0.0.1')
52
53 assert.throws(
54 () => reg.addBackend('nonexistent', 'node1', 8080),
55 { message: 'Service "nonexistent" not found' }
56 )
57 })
58
59 it('addBackend throws if node does not exist', () => {
60 const { reg } = createTestRegistry()
61 reg.addNode('node1', '10.0.0.1')
62 reg.addService('node1', 'web', 8080)
63
64 assert.throws(
65 () => reg.addBackend('web', 'ghost', 9090),
66 { message: 'Node "ghost" not found' }
67 )
68 })
69
70 it('removeBackend reverts to single-node if one backend left', () => {
71 const { reg } = createTestRegistry()
72 reg.addNode('node1', '10.0.0.1')
73 reg.addNode('node2', '10.0.0.2')
74 reg.addService('node1', 'web', 8080)
75 reg.addBackend('web', 'node2', 9090, 'standby')
76
77 reg.removeBackend('web', 'node2')
78
79 const svc = reg.listServices().find(s => s.name === 'web')
80 assert.equal(svc.backends, undefined)
81 assert.equal(svc.node, 'node1')
82 assert.equal(svc.port, 8080)
83 })
84
85 it('getServiceWithBackends returns resolved info', () => {
86 const { reg } = createTestRegistry()
87 reg.addNode('node1', '10.0.0.1')
88 reg.addNode('node2', '10.0.0.2')
89 reg.addService('node1', 'web', 8080)
90 reg.addBackend('web', 'node2', 9090, 'standby')
91
92 const result = reg.getServiceWithBackends('web')
93 assert.ok(result.resolvedBackends)
94 assert.equal(result.resolvedBackends.length, 2)
95 assert.equal(result.resolvedBackends[0].ip, '10.0.0.1')
96 assert.equal(result.resolvedBackends[0].node, 'node1')
97 assert.equal(result.resolvedBackends[0].port, 8080)
98 assert.equal(result.resolvedBackends[0].mode, 'active')
99 assert.equal(result.resolvedBackends[1].ip, '10.0.0.2')
100 assert.equal(result.resolvedBackends[1].node, 'node2')
101 assert.equal(result.resolvedBackends[1].port, 9090)
102 assert.equal(result.resolvedBackends[1].mode, 'standby')
103 })
104
105 it('getServiceWithBackends synthesizes array for single-node service', () => {
106 const { reg } = createTestRegistry()
107 reg.addNode('node1', '10.0.0.1')
108 reg.addService('node1', 'web', 8080)
109
110 const result = reg.getServiceWithBackends('web')
111 assert.ok(result.resolvedBackends)
112 assert.equal(result.resolvedBackends.length, 1)
113 assert.equal(result.resolvedBackends[0].ip, '10.0.0.1')
114 assert.equal(result.resolvedBackends[0].node, 'node1')
115 assert.equal(result.resolvedBackends[0].port, 8080)
116 assert.equal(result.resolvedBackends[0].mode, 'active')
117 })
118})