← back to Tools Dw Hub

server.js

177 lines

#!/usr/bin/env node
// tools.designerwallcoverings.com — zero-dependency launcher hub for all DW build tools.
// Node built-ins only (http/fs/path/net) — no npm install required.
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 0;                       // 0 = OS-assigned free port (local dev)
const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DW2024!';
const PROBE_HOST = process.env.PROBE_HOST || '127.0.0.1'; // where the tool apps listen (localhost on Mac2; tailnet IP when public)
const TOOL_HOST = process.env.TOOL_HOST || '';            // host the UI builds Launch links against ('' = the browser's own hostname)
const [AUTH_USER, AUTH_PASS] = BASIC_AUTH.split(':');
const ROOT = __dirname;

const manifest = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'tools.json'), 'utf8'));

// --- HTTP marker probe: 'up' ONLY if the response is really THIS tool ---
// 200 + body contains the tool's marker string => up (true-positive).
// 401 => up (the tool's own auth wall answered — alive).
// TCP-open but wrong body (another app squatting the port) => down. This kills the
// false-positive class where port 5000 = macOS ControlCenter read as a live tool.
function probe(port, marker, timeout = 2500, path = '/', hop = 0, host = PROBE_HOST) {
  return new Promise((resolve) => {
    if (!port) return resolve('n/a');
    const req = http.get({ host, port, path, timeout }, (res) => {
      if (res.statusCode === 401) { res.resume(); return resolve('up'); }
      // one-hop redirect follow (auth tools 302 to /login — still THIS tool if the target marker-matches)
      if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && hop < 1) {
        res.resume();
        const loc = res.headers.location.startsWith('http') ? new URL(res.headers.location).pathname : res.headers.location;
        return probe(port, marker, timeout, loc, hop + 1, host).then(resolve);
      }
      let body = '';
      res.on('data', (c) => { if (body.length < 65536) body += c; });
      res.on('end', () => {
        if (res.statusCode === 200 && (!marker || body.includes(marker))) return resolve('up');
        resolve('down');
      });
    });
    req.on('timeout', () => { req.destroy(); resolve('down'); });
    req.on('error', () => resolve('down'));
  });
}

// --- Start/Stop tools from the hub ---
// LOCAL mode (Mac2, where the tools live): spawns the manifest's own start command in a
// detached process group, injecting secrets from secrets-manager at RUNTIME (never stored).
// REMOTE mode (Kamatera public hub): proxies /api/start|stop to the Mac2 hub over tailnet
// (MAC_HUB env). Only manifest-defined commands ever run — slugs resolve server-side.
const { spawn, execSync } = require('child_process');
const os = require('os');
const IS_TOOL_HOST = process.env.TOOLS_LOCAL === '1' || os.platform() === 'darwin';
const MAC_HUB = process.env.MAC_HUB || '';
const LOG_DIR = '/tmp/dw-tools';

function loadSecretsEnv() {
  const env = {};
  try {
    const raw = fs.readFileSync(path.join(process.env.HOME || '', 'Projects/secrets-manager/.env'), 'utf8');
    for (const line of raw.split('\n')) {
      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
      if (m) env[m[1]] = m[2].replace(/^["']|["']\s*$/g, '');
    }
  } catch { /* no secrets file — tools that need keys will warn themselves */ }
  return env;
}

function pidOnPort(port) {
  try { return parseInt(execSync(`lsof -nP -tiTCP:${port} -sTCP:LISTEN`).toString().split('\n')[0], 10) || null; }
  catch { return null; }
}

function startTool(t) {
  if (!t || !t.start || t.cli || t.missingSource) return { ok: false, error: 'not startable' };
  if (t.port && pidOnPort(t.port)) return { ok: true, already: true };
  fs.mkdirSync(LOG_DIR, { recursive: true });
  const log = fs.openSync(path.join(LOG_DIR, `${t.slug}.log`), 'a');
  const env = {
    ...process.env, ...loadSecretsEnv(),
    ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'DW2024!',
    DRY_RUN: '1',                                   // hub-launched taggers never push; re-launch manually for writes
    VISION_SCAN_CAP: process.env.VISION_SCAN_CAP || '25',
  };
  if (t.port) { env.PORT = String(t.port); env.NEXTAUTH_URL = `http://localhost:${t.port}`; }
  const child = spawn('bash', ['-lc', t.start], { detached: true, stdio: ['ignore', log, log], env });
  child.unref();
  return { ok: true, pid: child.pid };
}

function stopTool(t) {
  if (!t || !t.port) return { ok: false, error: 'no port' };
  const pid = pidOnPort(t.port);
  if (!pid) return { ok: true, already: true };
  try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch {} }
  setTimeout(() => { const p2 = pidOnPort(t.port); if (p2) { try { process.kill(p2, 'SIGKILL'); } catch {} } }, 1500);
  return { ok: true, killed: pid };
}

function proxyToMacHub(req, res, urlPath) {
  const target = new URL(urlPath, MAC_HUB);
  const preq = http.request(target, { method: 'POST', headers: { Authorization: req.headers.authorization || '' }, timeout: 25000 }, (pres) => {
    res.writeHead(pres.statusCode, { 'Content-Type': 'application/json' });
    pres.pipe(res);
  });
  preq.on('error', (e) => { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: `Mac2 hub unreachable: ${e.message}` })); });
  preq.on('timeout', () => { preq.destroy(); });
  preq.end();
}

function unauthorized(res) {
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Tools"', 'Content-Type': 'text/plain' });
  res.end('Authentication required');
}
function checkAuth(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
  return u === AUTH_USER && p === AUTH_PASS;
}

const server = http.createServer(async (req, res) => {
  const url = req.url.split('?')[0];

  // /healthz is OPEN (before auth) so the fleet keepalive never misreads a healthy 401 as dead.
  if (url === '/healthz') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ ok: true, service: 'tools-dw-hub' }));
  }

  if (!checkAuth(req)) return unauthorized(res);

  if (url === '/api/tools') {
    const m = manifest();
    m.hub.toolHost = TOOL_HOST;   // '' → UI falls back to the browser's own hostname (local dev)
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(m));
  }

  // Start/Stop a tool: local hub executes; public hub proxies to the Mac2 hub over tailnet.
  const action = url.match(/^\/api\/(start|stop)\/([a-z0-9-]+)$/);
  if (action && req.method === 'POST') {
    const [, verb, slug] = action;
    const t = manifest().tools.find((x) => x.slug === slug);
    if (!t) { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'unknown tool' })); }
    if (!IS_TOOL_HOST) {
      if (!MAC_HUB) { res.writeHead(501, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'MAC_HUB not configured' })); }
      return proxyToMacHub(req, res, url);
    }
    const result = verb === 'start' ? startTool(t) : stopTool(t);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(result));
  }

  if (url === '/api/status') {
    const { tools } = manifest();
    const results = await Promise.all(tools.map(async (t) => ({ slug: t.slug, status: await probe(t.port, t.marker, 2500, t.probePath || '/', 0, t.probeHost || PROBE_HOST) })));
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(Object.fromEntries(results.map((r) => [r.slug, r.status]))));
  }

  // static
  const file = url === '/' ? '/index.html' : url;
  const fp = path.join(ROOT, 'public', path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
  fs.readFile(fp, (err, data) => {
    if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
    const ext = path.extname(fp);
    const type = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' }[ext] || 'application/octet-stream';
    res.writeHead(200, { 'Content-Type': type });
    res.end(data);
  });
});

server.listen(PORT, () => {
  const addr = server.address();
  console.log(`[tools-dw-hub] listening on http://127.0.0.1:${addr.port}  (auth ${AUTH_USER}/****, probing ${PROBE_HOST})`);
});