import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { Registry } from './registry.js'
import { Deployer } from './deployer.js'
import { generateConfig } from './config-generator.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const WEB_DIR = path.join(__dirname, '..', 'web')
// Endpoints that mutate prod or read arbitrary remote state / run sudo. These
// are gated by a token whenever the server is bound to the network — otherwise
// any LAN host could POST /api/deploy (real prod Apache deploy+reload) or read
// arbitrary files via /api/remote-config. Read-only status/config/dry-run stay
// open. Localhost mode trusts the local operator (matches the CLI).
const SENSITIVE = new Set(['/api/deploy', '/api/remote-config', '/api/apache-status'])
export function startServer (port, network) {
const host = network ? '0.0.0.0' : '127.0.0.1'
const token = network ? (process.env.SEVENRNX_UI_TOKEN || crypto.randomBytes(24).toString('hex')) : null
const server = http.createServer((req, res) => handleRequest(req, res, { network, token }))
server.listen(port, host, () => {
const addr = network ? `http://0.0.0.0:${port}` : `http://localhost:${port}`
console.log(`7rnx-proxy dashboard: ${addr}`)
if (token) {
console.log(`Network mode: sensitive endpoints require header x-7rnx-token: ${token}`)
}
})
return server
}
async function handleRequest (req, res, ctx) {
const url = new URL(req.url, 'http://localhost')
if (url.pathname === '/' || url.pathname === '/index.html') {
return serveFile(res, 'index.html', 'text/html')
}
if (url.pathname.startsWith('/api/')) {
return handleApi(req, res, url, ctx)
}
res.writeHead(404)
res.end('Not found')
}
function serveFile (res, filename, contentType) {
const filePath = path.join(WEB_DIR, filename)
try {
const content = fs.readFileSync(filePath, 'utf8')
res.writeHead(200, { 'Content-Type': contentType })
res.end(content)
} catch {
res.writeHead(404)
res.end('Not found')
}
}
function authorized (req, ctx) {
if (!ctx.token) return true // localhost mode — no token required
const provided = req.headers['x-7rnx-token'] ||
(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
return provided === ctx.token
}
async function handleApi (req, res, url, ctx) {
res.setHeader('Content-Type', 'application/json')
if (SENSITIVE.has(url.pathname) && !authorized(req, ctx)) {
res.writeHead(401)
res.end(JSON.stringify({ error: 'unauthorized (missing/invalid x-7rnx-token)' }))
return
}
try {
const registry = new Registry().load()
const deployer = new Deployer(registry.data)
switch (url.pathname) {
case '/api/status': {
const services = registry.listServices()
const results = deployer.checkServices(registry.data.nodes, services)
res.writeHead(200)
res.end(JSON.stringify({
nodes: registry.listNodes(),
services: services.map(s => {
const check = results.find(r => r.node === s.node && r.name === s.name)
return { ...s, status: check ? check.status : 'UNKNOWN' }
})
}))
break
}
case '/api/config': {
res.writeHead(200)
res.end(JSON.stringify({ config: registry.data }))
break
}
case '/api/dry-run': {
const config = generateConfig(registry.data)
res.writeHead(200)
res.end(JSON.stringify({ config }))
break
}
case '/api/remote-config': {
const configPath = url.searchParams.get('path') || '/etc/apache2/sites-enabled/000-default-le-ssl.conf'
const content = deployer.fetchRemoteConfig(configPath)
res.writeHead(200)
res.end(JSON.stringify({ config: content }))
break
}
case '/api/deploy': {
if (req.method !== 'POST') {
res.writeHead(405)
res.end(JSON.stringify({ error: 'POST required' }))
break
}
try {
deployer.deploy()
res.writeHead(200)
res.end(JSON.stringify({ ok: true, message: 'Deploy complete' }))
} catch (err) {
res.writeHead(500)
res.end(JSON.stringify({ ok: false, error: err.message }))
}
break
}
case '/api/apache-status': {
try {
const active = deployer._ssh('sudo systemctl is-active apache2 2>&1').trim()
const test = deployer._ssh('sudo apachectl configtest 2>&1').trim()
res.writeHead(200)
res.end(JSON.stringify({ active, configTest: test }))
} catch (err) {
res.writeHead(500)
res.end(JSON.stringify({ error: err.message }))
}
break
}
default:
res.writeHead(404)
res.end(JSON.stringify({ error: 'Unknown endpoint' }))
}
} catch (err) {
res.writeHead(500)
res.end(JSON.stringify({ error: err.message }))
}
}