OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy commits / bae5407

harden deploy safety, ssh injection, balancer timeout, web auth, input validation
Audit fixes for the 7rnx-proxy CLI: - deployer.deploy(): the old flow ran `apachectl configtest` BEFORE copying the new config into place (so it tested the pre-deploy tree) and then copied + reloaded UNCONDITIONALLY — the documented "safe deploy" guarantee was false. New flow: backup -> install new -> configtest (now tests the real tree) -> reload only on pass, else restore backup + throw. Uses only cp/apachectl/ reload (the NOPASSWD sudoers set). - deployer: execFileSync instead of execSync (no local shell), and shellQuote() the caller-supplied path in fetchRemoteConfig() — closes command injection reachable from `remote config <path>` and the web ?path= param. - config-generator: balancer member timeout was 5s + failontimeout=On, which killed slow large transfers (the #101 bootstrap-tarball 502s). Now a generous default (300s, per-service `svc.timeout` override); liveness stays with the TCP health checks. assertSafe() rejects values that would break an Apache directive. - registry: validate ip / petname / service-name / rootPath / port / mode at the point of entry. - web.js: token-gate the sensitive endpoints (/api/deploy, /api/remote-config, /api/apache-status) whenever bound to the network (--network) so a LAN host can't trigger a prod deploy or read arbitrary remote files. Localhost trusts the local operator (matches the CLI). Tests: new test/hardening.test.js (deploy gating, shellQuote, timeout, registry validation); existing config-generator assertion updated. 23/23 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bae540785b5988b26d5b4831fe66b2c1529adcfc
by plankatron <fleeky@gmail.com> · 2026-07-02T14:01:57.000Z

Changed files (7) +283 -27

lib/config-generator.js +29 -2
……@@ -1,3 +1,14 @@
1// Reject values that would break out of an Apache directive when interpolated
2// (whitespace, quotes, angle brackets, CR/LF). Defense-in-depth on top of the
3// registry's input validation — a malformed value must never reach the config
4// that gets deployed, especially now that deploy() rolls back on configtest
5// failure rather than silently shipping garbage.
6function assertSafe (value, kind) {
7 if (typeof value !== 'string' || value.length === 0 || /[\s"'<>\n\r]/.test(value)) {
8 throw new Error(`unsafe ${kind} for Apache config: ${JSON.stringify(value)}`)
9 }
10}
11
112export function generateConfig (registry) {
213 const lines = []
314 const now = new Date().toISOString()
……@@ -31,6 +42,16 @@ export function generateConfig (registry) {
3142 const urlPath = svc.rootPath || `/${svc.name}`
3243 const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
3344 const balancerName = svc.name
45 assertSafe(balancerName, 'service name')
46 assertSafe(urlPathClean, 'url path')
47
48 // Per-member request timeout (seconds). The old value (5s, failontimeout=On)
49 // killed slow LARGE transfers — a >5s data stall on e.g. the pear-git
50 // bootstrap tarball tripped failover → 502 (issue #101). Liveness is handled
51 // separately by the TCP health checks below (a dead member is marked down in
52 // ~hcinterval*hcfails = ~6s), so the request timeout can be generous without
53 // slowing failover. Override per service with `svc.timeout`.
54 const memberTimeout = svc.timeout ? Number(svc.timeout) : 300
3455
3556 lines.push(`# === ${svc.name} (balancer) ===`)
3657 lines.push('')
……@@ -43,6 +64,7 @@ export function generateConfig (registry) {
4364 for (const backend of svc.backends) {
4465 const node = nodeMap.get(backend.node)
4566 if (!node) continue
67 assertSafe(node.ip, 'backend ip')
4668 const base = ` BalancerMember http://${node.ip}:${backend.port}`
4769 if (backend.mode === 'standby') {
4870 lines.push(`${base} status=+H ${hc}`)
……@@ -54,7 +76,7 @@ export function generateConfig (registry) {
5476 }
5577 }
5678 }
57 lines.push(' ProxySet failontimeout=On timeout=5')
79 lines.push(` ProxySet timeout=${memberTimeout}`)
5880 lines.push('</Proxy>')
5981 lines.push('')
6082
……@@ -69,7 +91,10 @@ export function generateConfig (registry) {
6991 lines.push('</Location>')
7092 lines.push('')
7193
72 // WebSocket rewrite to first active backend
94 // WebSocket rewrite to the first active backend. NOTE: a RewriteRule cannot
95 // health-check or fail over, so WS traffic is pinned to this one member —
96 // if it goes down, WS upgrades break until it returns (HTTP still fails over
97 // via the balancer). Acceptable while WS is non-critical; revisit if needed.
7398 if (firstActiveIp) {
7499 lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]')
75100 lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${firstActiveIp}:${firstActivePort}$1 [P,L]`)
……@@ -98,9 +123,11 @@ export function generateConfig (registry) {
98123 lines.push(`# === ${label} ===`)
99124 lines.push('')
100125
126 assertSafe(node.ip, 'node ip')
101127 for (const svc of services) {
102128 const urlPath = svc.rootPath || `/${petname}/${svc.name}`
103129 const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
130 assertSafe(urlPathClean, 'url path')
104131 const backend = `http://${node.ip}:${svc.port}`
105132
106133 lines.push(`<Location ${urlPathClean}>`)
lib/deployer.js +55 -18
……@@ -1,23 +1,34 @@
1import { execSync } from 'node:child_process'
1import { execFileSync } from 'node:child_process'
22import fs from 'node:fs'
33import os from 'node:os'
44import path from 'node:path'
55import { generateConfig } from './config-generator.js'
66
7// POSIX single-quote escaping for values that must be interpolated into a
8// remote shell command (e.g. a caller-supplied config path). Wraps the value
9// in single quotes and escapes any embedded single quote.
10export function shellQuote (s) {
11 return "'" + String(s).replace(/'/g, "'\\''") + "'"
12}
13
714export class Deployer {
815 constructor (registry) {
916 this.registry = registry
1017 this.server = registry.server
1118 }
1219
20 // execFileSync (not execSync) so the LOCAL shell never parses these args —
21 // the command string is handed to ssh as a single argv entry and executed by
22 // the REMOTE shell. Anything interpolated into `cmd` that comes from user
23 // input must still be shellQuote()'d for the remote shell (see fetchRemoteConfig).
1324 _ssh (cmd) {
14 const full = `ssh ${this.server.user}@${this.server.host} "${cmd}"`
15 return execSync(full, { encoding: 'utf8', timeout: 30000 })
25 const dest = `${this.server.user}@${this.server.host}`
26 return execFileSync('ssh', [dest, cmd], { encoding: 'utf8', timeout: 30000 })
1627 }
1728
1829 _scp (localPath, remotePath) {
19 const full = `scp ${localPath} ${this.server.user}@${this.server.host}:${remotePath}`
20 return execSync(full, { encoding: 'utf8', timeout: 30000 })
30 const dest = `${this.server.user}@${this.server.host}:${remotePath}`
31 return execFileSync('scp', [localPath, dest], { encoding: 'utf8', timeout: 30000 })
2132 }
2233
2334 dryRun () {
……@@ -36,15 +47,38 @@ export class Deployer {
3647 console.log('Uploading config to 7rnx.net...')
3748 this._scp(tmpFile, '/tmp/7rnx-proxy.conf')
3849
39 // Validate config before applying
40 console.log('Running config test...')
41 const testResult = this._ssh('sudo apachectl configtest 2>&1')
42 console.log(testResult.trim())
43
44 // Copy to apache2 dir (Included by SSL vhost) and reload
45 console.log('Applying config and reloading Apache...')
46 this._ssh('sudo cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf && sudo systemctl reload apache2')
50 // Validate-then-apply with rollback. `apachectl configtest` only checks
51 // what is actually Included, so the new file MUST be installed before we
52 // can test it — a backup makes the failure path a clean restore. This
53 // fixes the old flow, which ran configtest BEFORE copying (so it tested
54 // the pre-deploy tree) and then copied + reloaded UNCONDITIONALLY.
55 // Uses only cp / apachectl / systemctl reload (the NOPASSWD sudoers set).
56 console.log('Validating new config against the live tree (rollback on failure)...')
57 const remote = [
58 'sudo cp /etc/apache2/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf.bak 2>/dev/null || true',
59 'sudo cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf',
60 'if sudo apachectl configtest 2>&1; then',
61 ' sudo systemctl reload apache2 && echo __DEPLOY_OK__',
62 'else',
63 ' sudo cp /etc/apache2/7rnx-proxy.conf.bak /etc/apache2/7rnx-proxy.conf 2>/dev/null || true',
64 ' echo __DEPLOY_FAILED_ROLLED_BACK__',
65 ' exit 1',
66 'fi'
67 ].join('\n')
68
69 let out
70 try {
71 out = this._ssh(remote)
72 } catch (err) {
73 // Non-zero exit (configtest failed → rolled back). Surface remote output.
74 const detail = (err.stdout || '') + (err.stderr || '') || err.message
75 throw new Error(`config test failed — rolled back, live config unchanged:\n${detail.trim()}`)
76 }
4777
78 console.log(out.trim())
79 if (out.indexOf('__DEPLOY_OK__') === -1) {
80 throw new Error('deploy did not confirm success (no __DEPLOY_OK__ marker)')
81 }
4882 console.log('Deploy complete.')
4983 } finally {
5084 fs.unlinkSync(tmpFile)
……@@ -69,7 +103,9 @@ export class Deployer {
69103
70104 fetchRemoteConfig (configPath) {
71105 try {
72 return this._ssh(`cat ${configPath}`)
106 // configPath may come from the CLI or the web ?path= param — quote it so
107 // it cannot break out of `cat <path>` on the remote shell.
108 return this._ssh(`cat ${shellQuote(configPath)}`)
73109 } catch (err) {
74110 console.error(`Failed to read ${configPath}:`, err.message)
75111 return null
……@@ -91,17 +127,18 @@ export class Deployer {
91127 }
92128
93129 for (const [ip, { node, services: svcs }] of byIp) {
94 // Build a single command that checks all ports on this node
130 // Ports are Number()-coerced in the registry, so this command is built
131 // only from numeric values.
95132 const portChecks = svcs.map(s => s.port).join(' ')
96133 const cmd = `for p in ${portChecks}; do if ss -tlnp 2>/dev/null | grep -q ":$p "; then echo "$p UP"; else echo "$p DOWN"; fi; done`
97134
98135 let output
99136 try {
100137 if (ip === '10.22.0.1') {
101 // tabertron is local — run directly
102 output = execSync(`bash -c '${cmd}'`, { encoding: 'utf8', timeout: 10000 })
138 // tabertron is local — run directly (no local shell parsing of cmd)
139 output = execFileSync('bash', ['-c', cmd], { encoding: 'utf8', timeout: 10000 })
103140 } else {
104 output = execSync(`ssh ${node.petname}.local '${cmd}'`, { encoding: 'utf8', timeout: 10000 })
141 output = execFileSync('ssh', [`${node.petname}.local`, cmd], { encoding: 'utf8', timeout: 10000 })
105142 }
106143 } catch {
107144 // Node unreachable
lib/registry.js +26 -0
……@@ -5,6 +5,23 @@ import os from 'node:os'
55const CONFIG_DIR = path.join(os.homedir(), '.config', '7rnx-proxy')
66const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json')
77
8// Input validation — everything here ends up interpolated into an Apache config
9// or an ssh host argument, so reject anything outside a safe character set at
10// the point of entry rather than trusting the config generator to catch it.
11const PETNAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/
12const PATH_RE = /^\/[A-Za-z0-9/_-]*$/
13
14function validIp (ip) {
15 const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
16 if (!m) return false
17 return m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255)
18}
19
20function validPort (p) {
21 const n = Number(p)
22 return Number.isInteger(n) && n > 0 && n < 65536
23}
24
825const DEFAULT_REGISTRY = {
926 server: {
1027 host: '10.22.0.3',
……@@ -39,6 +56,8 @@ export class Registry {
3956
4057 addNode (petname, ip, keetIdent) {
4158 if (!petname || !ip) throw new Error('petname and ip are required')
59 if (!PETNAME_RE.test(petname)) throw new Error(`invalid petname "${petname}" (allowed: letters, digits, "-", "_"; must start alphanumeric)`)
60 if (!validIp(ip)) throw new Error(`invalid IPv4 address "${ip}"`)
4261
4362 const existing = this.data.nodes.find(n => n.petname === petname)
4463 if (existing) throw new Error(`Node "${petname}" already exists`)
……@@ -79,6 +98,10 @@ export class Registry {
7998 if (!node) throw new Error(`Node "${nodePetname}" not found`)
8099
81100 if (!serviceName || !port) throw new Error('serviceName and port are required')
101 if (!PETNAME_RE.test(serviceName)) throw new Error(`invalid service name "${serviceName}" (allowed: letters, digits, "-", "_")`)
102 if (!validPort(port)) throw new Error(`invalid port "${port}"`)
103 if (rootPath && !PATH_RE.test(rootPath)) throw new Error(`invalid root path "${rootPath}" (must start with "/", allowed: letters, digits, "/", "-", "_")`)
104 if (prefix && !PATH_RE.test(prefix)) throw new Error(`invalid prefix "${prefix}" (must start with "/")`)
82105
83106 const existing = this.data.services.find(
84107 s => s.node === nodePetname && s.name === serviceName
……@@ -115,6 +138,9 @@ export class Registry {
115138 const node = this.getNode(nodePetname)
116139 if (!node) throw new Error(`Node "${nodePetname}" not found`)
117140
141 if (!validPort(port)) throw new Error(`invalid port "${port}"`)
142 if (mode && mode !== 'active' && mode !== 'standby') throw new Error(`invalid mode "${mode}" (allowed: active, standby)`)
143
118144 if (!service.backends) {
119145 service.backends = [{
120146 node: service.node,
lib/web.js +29 -5
……@@ -1,8 +1,8 @@
11import http from 'node:http'
22import fs from 'node:fs'
33import path from 'node:path'
4import crypto from 'node:crypto'
45import { fileURLToPath } from 'node:url'
5import { execSync } from 'node:child_process'
66import { Registry } from './registry.js'
77import { Deployer } from './deployer.js'
88import { generateConfig } from './config-generator.js'
……@@ -10,17 +10,28 @@ import { generateConfig } from './config-generator.js'
1010const __dirname = path.dirname(fileURLToPath(import.meta.url))
1111const WEB_DIR = path.join(__dirname, '..', 'web')
1212
13// Endpoints that mutate prod or read arbitrary remote state / run sudo. These
14// are gated by a token whenever the server is bound to the network — otherwise
15// any LAN host could POST /api/deploy (real prod Apache deploy+reload) or read
16// arbitrary files via /api/remote-config. Read-only status/config/dry-run stay
17// open. Localhost mode trusts the local operator (matches the CLI).
18const SENSITIVE = new Set(['/api/deploy', '/api/remote-config', '/api/apache-status'])
19
1320export function startServer (port, network) {
1421 const host = network ? '0.0.0.0' : '127.0.0.1'
15 const server = http.createServer(handleRequest)
22 const token = network ? (process.env.SEVENRNX_UI_TOKEN || crypto.randomBytes(24).toString('hex')) : null
23 const server = http.createServer((req, res) => handleRequest(req, res, { network, token }))
1624 server.listen(port, host, () => {
1725 const addr = network ? `http://0.0.0.0:${port}` : `http://localhost:${port}`
1826 console.log(`7rnx-proxy dashboard: ${addr}`)
27 if (token) {
28 console.log(`Network mode: sensitive endpoints require header x-7rnx-token: ${token}`)
29 }
1930 })
2031 return server
2132}
2233
23async function handleRequest (req, res) {
34async function handleRequest (req, res, ctx) {
2435 const url = new URL(req.url, 'http://localhost')
2536
2637 if (url.pathname === '/' || url.pathname === '/index.html') {
……@@ -28,7 +39,7 @@ async function handleRequest (req, res) {
2839 }
2940
3041 if (url.pathname.startsWith('/api/')) {
31 return handleApi(req, res, url)
42 return handleApi(req, res, url, ctx)
3243 }
3344
3445 res.writeHead(404)
……@@ -47,9 +58,22 @@ function serveFile (res, filename, contentType) {
4758 }
4859}
4960
50async function handleApi (req, res, url) {
61function authorized (req, ctx) {
62 if (!ctx.token) return true // localhost mode — no token required
63 const provided = req.headers['x-7rnx-token'] ||
64 (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
65 return provided === ctx.token
66}
67
68async function handleApi (req, res, url, ctx) {
5169 res.setHeader('Content-Type', 'application/json')
5270
71 if (SENSITIVE.has(url.pathname) && !authorized(req, ctx)) {
72 res.writeHead(401)
73 res.end(JSON.stringify({ error: 'unauthorized (missing/invalid x-7rnx-token)' }))
74 return
75 }
76
5377 try {
5478 const registry = new Registry().load()
5579 const deployer = new Deployer(registry.data)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "7rnx-proxy",
3 "version": "1.0.0",
3 "version": "1.1.0",
44 "description": "Reverse proxy management CLI for 7rnx.net Apache2 server",
55 "type": "module",
66 "bin": {
test/config-generator.test.js +1 -1
……@@ -43,7 +43,7 @@ describe('generateConfig', () => {
4343 assert.ok(output.includes('<Proxy balancer://pear-git>'), 'should have Proxy balancer block')
4444 assert.ok(output.includes('BalancerMember http://10.22.0.1:5005'), 'should have active member')
4545 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')
46 assert.ok(output.includes('ProxySet timeout=300'), 'should have ProxySet with generous default timeout')
4747 assert.ok(output.includes('<Location /git>'), 'should have Location block')
4848 assert.ok(output.includes('ProxyPass balancer://pear-git'), 'Location should proxy to balancer')
4949 assert.ok(output.includes('ProxyPassReverse balancer://pear-git'), 'should have ProxyPassReverse for balancer')
test/hardening.test.js +142 -0
……@@ -0,0 +1,142 @@
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 { Deployer, shellQuote } from '../lib/deployer.js'
7import { generateConfig } from '../lib/config-generator.js'
8import { Registry } from '../lib/registry.js'
9
10function fakeRegistryData () {
11 return {
12 server: { user: 'fleeky', host: '10.22.0.3' },
13 nodes: [
14 { petname: 'tabertron', ip: '10.22.0.1' },
15 { petname: 'stortron', ip: '10.22.0.2' }
16 ],
17 services: [
18 {
19 node: 'stortron',
20 name: 'pear-git',
21 port: 5005,
22 rootPath: '/git',
23 backends: [
24 { node: 'stortron', port: 5005, mode: 'active' },
25 { node: 'tabertron', port: 5005, mode: 'standby' }
26 ]
27 }
28 ]
29 }
30}
31
32function createTestRegistry () {
33 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-test-'))
34 const reg = new Registry(tmpDir)
35 reg.load()
36 return reg
37}
38
39describe('shellQuote', () => {
40 it('neutralises shell metacharacters', () => {
41 assert.equal(shellQuote('/etc/apache2/x.conf'), "'/etc/apache2/x.conf'")
42 assert.equal(shellQuote("a'; rm -rf /"), "'a'\\''; rm -rf /'")
43 })
44})
45
46describe('Deployer.deploy() — validate-then-apply with rollback', () => {
47 it('gates reload on configtest and confirms via marker (success path)', () => {
48 const d = new Deployer(fakeRegistryData())
49 let remoteCmd = null
50 d._scp = () => ''
51 d._ssh = (cmd) => { remoteCmd = cmd; return 'Syntax OK\n__DEPLOY_OK__\n' }
52
53 assert.doesNotThrow(() => d.deploy())
54
55 // The remote script must back up, install, THEN test, and only reload on pass.
56 assert.match(remoteCmd, /7rnx-proxy\.conf\.bak/, 'takes a backup')
57 const iTest = remoteCmd.indexOf('apachectl configtest')
58 const iReload = remoteCmd.indexOf('systemctl reload apache2')
59 const iInstall = remoteCmd.indexOf('cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf')
60 assert.ok(iInstall !== -1 && iTest !== -1 && iReload !== -1, 'all steps present')
61 assert.ok(iInstall < iTest, 'installs the new config BEFORE testing it')
62 assert.ok(iTest < iReload, 'tests BEFORE reloading')
63 assert.match(remoteCmd, /cp \/etc\/apache2\/7rnx-proxy\.conf\.bak \/etc\/apache2\/7rnx-proxy\.conf/, 'restores backup on failure')
64 })
65
66 it('throws (does not silently succeed) when configtest fails', () => {
67 const d = new Deployer(fakeRegistryData())
68 d._scp = () => ''
69 d._ssh = () => {
70 const err = new Error('Command failed')
71 err.stdout = 'AH00526: Syntax error\n__DEPLOY_FAILED_ROLLED_BACK__\n'
72 err.stderr = ''
73 throw err
74 }
75 assert.throws(() => d.deploy(), /rolled back|config test failed/i)
76 })
77
78 it('throws when the success marker is absent', () => {
79 const d = new Deployer(fakeRegistryData())
80 d._scp = () => ''
81 d._ssh = () => 'Syntax OK\n' // reload ran but no marker
82 assert.throws(() => d.deploy(), /confirm success/i)
83 })
84})
85
86describe('Deployer.fetchRemoteConfig — path is quoted', () => {
87 it('quotes a malicious path so it cannot break out of cat', () => {
88 const d = new Deployer(fakeRegistryData())
89 let seen = null
90 d._ssh = (cmd) => { seen = cmd; return '' }
91 d.fetchRemoteConfig('/etc/x; rm -rf /')
92 assert.equal(seen, "cat '/etc/x; rm -rf /'")
93 })
94})
95
96describe('config-generator — balancer timeout + safety', () => {
97 it('uses a generous member timeout by default (not the 5s that broke #101)', () => {
98 const out = generateConfig(fakeRegistryData())
99 assert.match(out, /ProxySet timeout=300/)
100 assert.doesNotMatch(out, /timeout=5\b/)
101 assert.doesNotMatch(out, /failontimeout=On/)
102 })
103
104 it('honours a per-service timeout override', () => {
105 const data = fakeRegistryData()
106 data.services[0].timeout = 600
107 assert.match(generateConfig(data), /ProxySet timeout=600/)
108 })
109
110 it('rejects a service name that would break the Apache config', () => {
111 const data = fakeRegistryData()
112 data.services[0].name = 'pear git' // space
113 assert.throws(() => generateConfig(data), /unsafe service name/)
114 })
115})
116
117describe('registry — input validation', () => {
118 it('rejects a malformed IP', () => {
119 const reg = createTestRegistry()
120 assert.throws(() => reg.addNode('n1', '999.1.1.1'), /invalid IPv4/)
121 assert.throws(() => reg.addNode('n2', 'not-an-ip'), /invalid IPv4/)
122 })
123
124 it('rejects a petname with shell/apache metacharacters', () => {
125 const reg = createTestRegistry()
126 assert.throws(() => reg.addNode('bad name', '10.0.0.1'), /invalid petname/)
127 assert.throws(() => reg.addNode('a;b', '10.0.0.1'), /invalid petname/)
128 })
129
130 it('rejects a bad port and a bad root path on addService', () => {
131 const reg = createTestRegistry()
132 reg.addNode('n1', '10.0.0.1')
133 assert.throws(() => reg.addService('n1', 'web', 70000), /invalid port/)
134 assert.throws(() => reg.addService('n1', 'web', 8080, '/', '/git; rm'), /invalid root path/)
135 })
136
137 it('accepts a valid node + service', () => {
138 const reg = createTestRegistry()
139 assert.doesNotThrow(() => reg.addNode('n1', '10.22.0.9'))
140 assert.doesNotThrow(() => reg.addService('n1', 'web', 8080, '/', '/web'))
141 })
142})