| … | … | @@ -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. | |
| 6 | function 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 | ||
| 1 | 12 | export function generateConfig (registry) { |
| 2 | 13 | const lines = [] |
| 3 | 14 | const now = new Date().toISOString() |
| … | … | @@ -31,6 +42,16 @@ export function generateConfig (registry) { |
| 31 | 42 | const urlPath = svc.rootPath || `/${svc.name}` |
| 32 | 43 | const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath |
| 33 | 44 | 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 | |
| 34 | 55 | |
| 35 | 56 | lines.push(`# === ${svc.name} (balancer) ===`) |
| 36 | 57 | lines.push('') |
| … | … | @@ -43,6 +64,7 @@ export function generateConfig (registry) { |
| 43 | 64 | for (const backend of svc.backends) { |
| 44 | 65 | const node = nodeMap.get(backend.node) |
| 45 | 66 | if (!node) continue |
| 67 | assertSafe(node.ip, 'backend ip') | |
| 46 | 68 | const base = ` BalancerMember http://${node.ip}:${backend.port}` |
| 47 | 69 | if (backend.mode === 'standby') { |
| 48 | 70 | lines.push(`${base} status=+H ${hc}`) |
| … | … | @@ -54,7 +76,7 @@ export function generateConfig (registry) { |
| 54 | 76 | } |
| 55 | 77 | } |
| 56 | 78 | } |
| 57 | lines.push(' ProxySet failontimeout=On timeout=5') | |
| 79 | lines.push(` ProxySet timeout=${memberTimeout}`) | |
| 58 | 80 | lines.push('</Proxy>') |
| 59 | 81 | lines.push('') |
| 60 | 82 | |
| … | … | @@ -69,7 +91,10 @@ export function generateConfig (registry) { |
| 69 | 91 | lines.push('</Location>') |
| 70 | 92 | lines.push('') |
| 71 | 93 | |
| 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. | |
| 73 | 98 | if (firstActiveIp) { |
| 74 | 99 | lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]') |
| 75 | 100 | lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${firstActiveIp}:${firstActivePort}$1 [P,L]`) |
| … | … | @@ -98,9 +123,11 @@ export function generateConfig (registry) { |
| 98 | 123 | lines.push(`# === ${label} ===`) |
| 99 | 124 | lines.push('') |
| 100 | 125 | |
| 126 | assertSafe(node.ip, 'node ip') | |
| 101 | 127 | for (const svc of services) { |
| 102 | 128 | const urlPath = svc.rootPath || `/${petname}/${svc.name}` |
| 103 | 129 | const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath |
| 130 | assertSafe(urlPathClean, 'url path') | |
| 104 | 131 | const backend = `http://${node.ip}:${svc.port}` |
| 105 | 132 | |
| 106 | 133 | lines.push(`<Location ${urlPathClean}>`) |
| … | … | @@ -1,23 +1,34 @@ |
| 1 | import { execSync } from 'node:child_process' | |
| 1 | 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 | 5 | import { generateConfig } from './config-generator.js' |
| 6 | 6 | |
| 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. | |
| 10 | export function shellQuote (s) { | |
| 11 | return "'" + String(s).replace(/'/g, "'\\''") + "'" | |
| 12 | } | |
| 13 | ||
| 7 | 14 | export class Deployer { |
| 8 | 15 | constructor (registry) { |
| 9 | 16 | this.registry = registry |
| 10 | 17 | this.server = registry.server |
| 11 | 18 | } |
| 12 | 19 | |
| 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). | |
| 13 | 24 | _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 }) | |
| 16 | 27 | } |
| 17 | 28 | |
| 18 | 29 | _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 }) | |
| 21 | 32 | } |
| 22 | 33 | |
| 23 | 34 | dryRun () { |
| … | … | @@ -36,15 +47,38 @@ export class Deployer { |
| 36 | 47 | console.log('Uploading config to 7rnx.net...') |
| 37 | 48 | this._scp(tmpFile, '/tmp/7rnx-proxy.conf') |
| 38 | 49 | |
| 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 | } | |
| 47 | 77 | |
| 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 | } | |
| 48 | 82 | console.log('Deploy complete.') |
| 49 | 83 | } finally { |
| 50 | 84 | fs.unlinkSync(tmpFile) |
| … | … | @@ -69,7 +103,9 @@ export class Deployer { |
| 69 | 103 | |
| 70 | 104 | fetchRemoteConfig (configPath) { |
| 71 | 105 | 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)}`) | |
| 73 | 109 | } catch (err) { |
| 74 | 110 | console.error(`Failed to read ${configPath}:`, err.message) |
| 75 | 111 | return null |
| … | … | @@ -91,17 +127,18 @@ export class Deployer { |
| 91 | 127 | } |
| 92 | 128 | |
| 93 | 129 | 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. | |
| 95 | 132 | const portChecks = svcs.map(s => s.port).join(' ') |
| 96 | 133 | 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` |
| 97 | 134 | |
| 98 | 135 | let output |
| 99 | 136 | try { |
| 100 | 137 | 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 }) | |
| 103 | 140 | } 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 }) | |
| 105 | 142 | } |
| 106 | 143 | } catch { |
| 107 | 144 | // Node unreachable |
| … | … | @@ -5,6 +5,23 @@ import os from 'node:os' |
| 5 | 5 | const CONFIG_DIR = path.join(os.homedir(), '.config', '7rnx-proxy') |
| 6 | 6 | const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json') |
| 7 | 7 | |
| 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. | |
| 11 | const PETNAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ | |
| 12 | const PATH_RE = /^\/[A-Za-z0-9/_-]*$/ | |
| 13 | ||
| 14 | function 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 | ||
| 20 | function validPort (p) { | |
| 21 | const n = Number(p) | |
| 22 | return Number.isInteger(n) && n > 0 && n < 65536 | |
| 23 | } | |
| 24 | ||
| 8 | 25 | const DEFAULT_REGISTRY = { |
| 9 | 26 | server: { |
| 10 | 27 | host: '10.22.0.3', |
| … | … | @@ -39,6 +56,8 @@ export class Registry { |
| 39 | 56 | |
| 40 | 57 | addNode (petname, ip, keetIdent) { |
| 41 | 58 | 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}"`) | |
| 42 | 61 | |
| 43 | 62 | const existing = this.data.nodes.find(n => n.petname === petname) |
| 44 | 63 | if (existing) throw new Error(`Node "${petname}" already exists`) |
| … | … | @@ -79,6 +98,10 @@ export class Registry { |
| 79 | 98 | if (!node) throw new Error(`Node "${nodePetname}" not found`) |
| 80 | 99 | |
| 81 | 100 | 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 "/")`) | |
| 82 | 105 | |
| 83 | 106 | const existing = this.data.services.find( |
| 84 | 107 | s => s.node === nodePetname && s.name === serviceName |
| … | … | @@ -115,6 +138,9 @@ export class Registry { |
| 115 | 138 | const node = this.getNode(nodePetname) |
| 116 | 139 | if (!node) throw new Error(`Node "${nodePetname}" not found`) |
| 117 | 140 | |
| 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 | ||
| 118 | 144 | if (!service.backends) { |
| 119 | 145 | service.backends = [{ |
| 120 | 146 | node: service.node, |
| … | … | @@ -1,8 +1,8 @@ |
| 1 | 1 | import http from 'node:http' |
| 2 | 2 | import fs from 'node:fs' |
| 3 | 3 | import path from 'node:path' |
| 4 | import crypto from 'node:crypto' | |
| 4 | 5 | import { fileURLToPath } from 'node:url' |
| 5 | import { execSync } from 'node:child_process' | |
| 6 | 6 | import { Registry } from './registry.js' |
| 7 | 7 | import { Deployer } from './deployer.js' |
| 8 | 8 | import { generateConfig } from './config-generator.js' |
| … | … | @@ -10,17 +10,28 @@ import { generateConfig } from './config-generator.js' |
| 10 | 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
| 11 | 11 | const WEB_DIR = path.join(__dirname, '..', 'web') |
| 12 | 12 | |
| 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). | |
| 18 | const SENSITIVE = new Set(['/api/deploy', '/api/remote-config', '/api/apache-status']) | |
| 19 | ||
| 13 | 20 | export function startServer (port, network) { |
| 14 | 21 | 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 })) | |
| 16 | 24 | server.listen(port, host, () => { |
| 17 | 25 | const addr = network ? `http://0.0.0.0:${port}` : `http://localhost:${port}` |
| 18 | 26 | console.log(`7rnx-proxy dashboard: ${addr}`) |
| 27 | if (token) { | |
| 28 | console.log(`Network mode: sensitive endpoints require header x-7rnx-token: ${token}`) | |
| 29 | } | |
| 19 | 30 | }) |
| 20 | 31 | return server |
| 21 | 32 | } |
| 22 | 33 | |
| 23 | async function handleRequest (req, res) { | |
| 34 | async function handleRequest (req, res, ctx) { | |
| 24 | 35 | const url = new URL(req.url, 'http://localhost') |
| 25 | 36 | |
| 26 | 37 | if (url.pathname === '/' || url.pathname === '/index.html') { |
| … | … | @@ -28,7 +39,7 @@ async function handleRequest (req, res) { |
| 28 | 39 | } |
| 29 | 40 | |
| 30 | 41 | if (url.pathname.startsWith('/api/')) { |
| 31 | return handleApi(req, res, url) | |
| 42 | return handleApi(req, res, url, ctx) | |
| 32 | 43 | } |
| 33 | 44 | |
| 34 | 45 | res.writeHead(404) |
| … | … | @@ -47,9 +58,22 @@ function serveFile (res, filename, contentType) { |
| 47 | 58 | } |
| 48 | 59 | } |
| 49 | 60 | |
| 50 | async function handleApi (req, res, url) { | |
| 61 | function 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 | ||
| 68 | async function handleApi (req, res, url, ctx) { | |
| 51 | 69 | res.setHeader('Content-Type', 'application/json') |
| 52 | 70 | |
| 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 | ||
| 53 | 77 | try { |
| 54 | 78 | const registry = new Registry().load() |
| 55 | 79 | const deployer = new Deployer(registry.data) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "7rnx-proxy", |
| 3 | "version": "1.0.0", | |
| 3 | "version": "1.1.0", | |
| 4 | 4 | "description": "Reverse proxy management CLI for 7rnx.net Apache2 server", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "bin": { |
| … | … | @@ -43,7 +43,7 @@ describe('generateConfig', () => { |
| 43 | 43 | assert.ok(output.includes('<Proxy balancer://pear-git>'), 'should have Proxy balancer block') |
| 44 | 44 | assert.ok(output.includes('BalancerMember http://10.22.0.1:5005'), 'should have active member') |
| 45 | 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') | |
| 46 | assert.ok(output.includes('ProxySet timeout=300'), 'should have ProxySet with generous default timeout') | |
| 47 | 47 | assert.ok(output.includes('<Location /git>'), 'should have Location block') |
| 48 | 48 | assert.ok(output.includes('ProxyPass balancer://pear-git'), 'Location should proxy to balancer') |
| 49 | 49 | assert.ok(output.includes('ProxyPassReverse balancer://pear-git'), 'should have ProxyPassReverse for balancer') |
| … | … | @@ -0,0 +1,142 @@ |
| 1 | import { describe, it } from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import fs from 'node:fs' | |
| 4 | import path from 'node:path' | |
| 5 | import os from 'node:os' | |
| 6 | import { Deployer, shellQuote } from '../lib/deployer.js' | |
| 7 | import { generateConfig } from '../lib/config-generator.js' | |
| 8 | import { Registry } from '../lib/registry.js' | |
| 9 | ||
| 10 | function 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 | ||
| 32 | function 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 | ||
| 39 | describe('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 | ||
| 46 | describe('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 | ||
| 86 | describe('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 | ||
| 96 | describe('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 | ||
| 117 | describe('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 | }) |