import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { generateConfig, generateVhosts } from './config-generator.js'
// Host-routed vhosts land in sites-enabled/ (top-level, loaded by apache2.conf's
// IncludeOptional sites-enabled/*.conf) so <VirtualHost> blocks are valid. The path is
// fixed to match the NOPASSWD sudoers rule installed by setup-7rnx-server.sh.
const VHOSTS_REMOTE = '/etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf'
const VHOSTS_TMP = '/tmp/7rnx-proxy-vhosts.conf'
// POSIX single-quote escaping for values that must be interpolated into a
// remote shell command (e.g. a caller-supplied config path). Wraps the value
// in single quotes and escapes any embedded single quote.
export function shellQuote (s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'"
}
export class Deployer {
constructor (registry) {
this.registry = registry
this.server = registry.server
}
// execFileSync (not execSync) so the LOCAL shell never parses these args —
// the command string is handed to ssh as a single argv entry and executed by
// the REMOTE shell. Anything interpolated into `cmd` that comes from user
// input must still be shellQuote()'d for the remote shell (see fetchRemoteConfig).
_ssh (cmd) {
const dest = `${this.server.user}@${this.server.host}`
return execFileSync('ssh', [dest, cmd], { encoding: 'utf8', timeout: 30000 })
}
_scp (localPath, remotePath) {
const dest = `${this.server.user}@${this.server.host}:${remotePath}`
return execFileSync('scp', [localPath, dest], { encoding: 'utf8', timeout: 30000 })
}
dryRun () {
return generateConfig(this.registry)
}
deploy () {
const config = generateConfig(this.registry)
// Write to temp file
const tmpFile = path.join(os.tmpdir(), '7rnx-proxy.conf')
fs.writeFileSync(tmpFile, config)
try {
// Upload config to remote /tmp
console.log('Uploading config to 7rnx.net...')
this._scp(tmpFile, '/tmp/7rnx-proxy.conf')
// Validate-then-apply with rollback. `apachectl configtest` only checks
// what is actually Included, so the new file MUST be installed before we
// can test it — a backup makes the failure path a clean restore. This
// fixes the old flow, which ran configtest BEFORE copying (so it tested
// the pre-deploy tree) and then copied + reloaded UNCONDITIONALLY.
// Uses only cp / apachectl / systemctl reload (the NOPASSWD sudoers set).
console.log('Validating new config against the live tree (rollback on failure)...')
const remote = [
'sudo cp /etc/apache2/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf.bak 2>/dev/null || true',
'sudo cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf',
'if sudo apachectl configtest 2>&1; then',
' sudo systemctl reload apache2 && echo __DEPLOY_OK__',
'else',
' sudo cp /etc/apache2/7rnx-proxy.conf.bak /etc/apache2/7rnx-proxy.conf 2>/dev/null || true',
' echo __DEPLOY_FAILED_ROLLED_BACK__',
' exit 1',
'fi'
].join('\n')
let out
try {
out = this._ssh(remote)
} catch (err) {
// Non-zero exit (configtest failed → rolled back). Surface remote output.
const detail = (err.stdout || '') + (err.stderr || '') || err.message
throw new Error(`config test failed — rolled back, live config unchanged:\n${detail.trim()}`)
}
console.log(out.trim())
if (out.indexOf('__DEPLOY_OK__') === -1) {
throw new Error('deploy did not confirm success (no __DEPLOY_OK__ marker)')
}
console.log('Deploy complete.')
} finally {
fs.unlinkSync(tmpFile)
}
}
dryRunVhosts () {
return generateVhosts(this.registry)
}
// Deploy host-routed vhosts (vhost mode). Same validate-then-apply-with-rollback flow
// as deploy(), but to sites-enabled/ (top-level). Uses only cp / apachectl / systemctl
// reload — the fixed VHOSTS_REMOTE path matches the extra NOPASSWD sudoers rule.
deployVhosts () {
const config = generateVhosts(this.registry)
const tmpFile = path.join(os.tmpdir(), '7rnx-proxy-vhosts.conf')
fs.writeFileSync(tmpFile, config)
try {
console.log('Uploading vhosts config to 7rnx.net...')
this._scp(tmpFile, VHOSTS_TMP)
console.log('Validating vhosts against the live tree (rollback on failure)...')
const remote = [
`sudo cp ${VHOSTS_REMOTE} ${VHOSTS_REMOTE}.bak 2>/dev/null || true`,
`sudo cp ${VHOSTS_TMP} ${VHOSTS_REMOTE}`,
'if sudo apachectl configtest 2>&1; then',
' sudo systemctl reload apache2 && echo __DEPLOY_OK__',
'else',
` sudo cp ${VHOSTS_REMOTE}.bak ${VHOSTS_REMOTE} 2>/dev/null || true`,
' echo __DEPLOY_FAILED_ROLLED_BACK__',
' exit 1',
'fi'
].join('\n')
let out
try {
out = this._ssh(remote)
} catch (err) {
const detail = (err.stdout || '') + (err.stderr || '') || err.message
throw new Error(`vhosts config test failed — rolled back, live config unchanged:\n${detail.trim()}`)
}
console.log(out.trim())
if (out.indexOf('__DEPLOY_OK__') === -1) {
throw new Error('vhosts deploy did not confirm success (no __DEPLOY_OK__ marker)')
}
console.log('Vhosts deploy complete.')
} finally {
fs.unlinkSync(tmpFile)
}
}
status () {
try {
console.log('Apache status:')
const status = this._ssh('sudo systemctl is-active apache2 2>&1')
console.log(' Service:', status.trim())
const test = this._ssh('sudo apachectl configtest 2>&1')
console.log(' Config:', test.trim())
const conf = this._ssh('test -f /etc/apache2/7rnx-proxy.conf && echo "present" || echo "missing"')
console.log(' 7rnx-proxy.conf:', conf.trim())
} catch (err) {
console.error('Failed to check status:', err.message)
}
}
fetchRemoteConfig (configPath) {
try {
// configPath may come from the CLI or the web ?path= param — quote it so
// it cannot break out of `cat <path>` on the remote shell.
return this._ssh(`cat ${shellQuote(configPath)}`)
} catch (err) {
console.error(`Failed to read ${configPath}:`, err.message)
return null
}
}
checkServices (nodes, services) {
const results = []
const nodesByIp = new Map()
for (const n of nodes) nodesByIp.set(n.petname, n)
// Group services by node IP to batch SSH calls
const byIp = new Map()
for (const svc of services) {
const node = nodesByIp.get(svc.node)
if (!node) continue
if (!byIp.has(node.ip)) byIp.set(node.ip, { node, services: [] })
byIp.get(node.ip).services.push(svc)
}
for (const [ip, { node, services: svcs }] of byIp) {
// Ports are Number()-coerced in the registry, so this command is built
// only from numeric values.
const portChecks = svcs.map(s => s.port).join(' ')
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`
let output
try {
if (ip === '10.22.0.1') {
// tabertron is local — run directly (no local shell parsing of cmd)
output = execFileSync('bash', ['-c', cmd], { encoding: 'utf8', timeout: 10000 })
} else {
output = execFileSync('ssh', [`${node.petname}.local`, cmd], { encoding: 'utf8', timeout: 10000 })
}
} catch {
// Node unreachable
for (const svc of svcs) {
results.push({ node: node.petname, ip, name: svc.name, port: svc.port, status: 'UNREACHABLE' })
}
continue
}
const portStatus = new Map()
for (const line of output.trim().split('\n')) {
const [port, status] = line.split(' ')
portStatus.set(Number(port), status)
}
for (const svc of svcs) {
results.push({
node: node.petname,
ip,
name: svc.name,
port: svc.port,
status: portStatus.get(svc.port) || 'UNKNOWN'
})
}
}
return results
}
listRemoteConfigs () {
try {
return this._ssh('ls /etc/apache2/sites-enabled/')
} catch (err) {
console.error('Failed to list configs:', err.message)
return null
}
}
}