OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy / lib / web.js

156 lines · javascript ref:
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 }))
  }
}