← back to Projects Agentabrams

server.js

192 lines

#!/usr/bin/env node
// projects-agentabrams — projects.agentabrams.com
// Left rail: every buildable project (live pm2 port OR public domain).
// Right pane: the project's live site, rendered in-panel.
//
// Schema mirrors all.agentabrams.com's domain viewer: a searchable list drives
// an iframe that loads through /site/<slug>/… — a reverse proxy that injects the
// unified admin credential upstream (browsers refuse to Basic-auth a cross-origin
// iframe) and strips X-Frame-Options/CSP so every fleet site frames cleanly.
// Targets are BOTH https public domains and http loopback ports, resolved from
// projects.json (regenerate with `node build-projects.js`).
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 9702;
const ROOT = __dirname;
const AUTH = 'Basic ' + Buffer.from(process.env.BASIC_AUTH || 'admin:DW2024!').toString('base64');
const UPSTREAM_AUTH = 'Basic ' + Buffer.from(process.env.UPSTREAM_AUTH || 'admin:DW2024!').toString('base64');

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };

function loadProjects() {
  try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'projects.json'), 'utf8')); }
  catch (e) { console.error('loadProjects failed:', e.message); return []; }
}
// slug -> project record, rebuilt per request so an edited projects.json is picked
// up without a restart (the list is tiny; the read is sub-ms).
function bySlug() {
  const m = new Map();
  for (const p of loadProjects()) m.set(p.slug, p);
  return m;
}

// Parse a target URL into the pieces the proxy/probe need, picking http vs https.
function parseTarget(target) {
  const u = new URL(target);
  const secure = u.protocol === 'https:';
  return { secure, mod: secure ? https : http, host: u.hostname, port: u.port ? +u.port : (secure ? 443 : 80) };
}

// One request against a target (HEAD by default). status 0 = unreachable/timeout.
function fetch1(t, pathStr, withAuth, method) {
  return new Promise((resolve) => {
    const headers = withAuth ? { authorization: UPSTREAM_AUTH } : {};
    const opts = { host: t.host, port: t.port, method, path: pathStr, headers, timeout: 6000 };
    if (t.secure) opts.rejectUnauthorized = true;
    const req = t.mod.request(opts, (res) => {
      if (method === 'HEAD') { res.resume(); return resolve({ status: res.statusCode, location: res.headers.location, body: '' }); }
      let body = '', bytes = 0;
      res.on('data', (c) => { if (bytes < 24000) { body += c; bytes += c.length; } });
      res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, body }));
    });
    req.on('timeout', () => { req.destroy(); resolve({ status: 0 }); });
    req.on('error', () => resolve({ status: 0 }));
    req.end();
  });
}

const AUTH_PATH_RE = /\/(login|signin|sign-in|auth|sso|oauth|account\/login|users\/sign_in)/i;
const PW_FIELD_RE  = /<input[^>]+type=["']?password|name=["']?(password|passwd|pwd)["']?[^>]*>/i;

// Classify a project's reachability + auth posture so the rail can dot it.
// authType: basic (401 wall) | login (redirect/pw form) | open | down.
async function probe(p) {
  const started = Date.now();
  let t;
  try { t = parseTarget(p.target); } catch { return { slug: p.slug, up: false, authType: 'down', ms: 0 }; }
  let pathStr = '/', status = 0, gated = false, authFail = false, authType = 'open';

  for (let hop = 0; hop < 5; hop++) {
    const r = await fetch1(t, pathStr, false, 'HEAD');
    status = r.status;
    if (r.status === 0) { authType = 'down'; break; }
    if (r.status === 401) { gated = true; authType = 'basic'; break; }
    if (r.status >= 300 && r.status < 400 && r.location) {
      let loc; try { loc = new URL(r.location, `${t.secure ? 'https' : 'http'}://${t.host}:${t.port}`); } catch { break; }
      if (AUTH_PATH_RE.test(loc.pathname)) { gated = true; authType = 'login'; break; }
      pathStr = loc.pathname + loc.search;
      continue;
    }
    break;
  }
  // Basic gate → re-probe WITH the unified credential to tell a healthy gate from a dead pw.
  if (authType === 'basic') {
    const eff = await fetch1(t, '/', true, 'HEAD');
    authFail = eff.status === 401;
  }
  if (!gated && status === 200) {
    const g = await fetch1(t, pathStr, false, 'GET');
    if (PW_FIELD_RE.test(g.body || '')) { gated = true; authType = 'login'; }
  }
  const up = authType === 'basic' ? !authFail
           : authType === 'login' ? true
           : (status >= 200 && status < 400);
  return { slug: p.slug, ms: Date.now() - started, authType, authFail, up };
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://x');

  if (url.pathname === '/healthz') { res.writeHead(200); return res.end('ok'); }

  if (req.headers.authorization !== AUTH) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="projects-agentabrams"' });
    return res.end('auth required');
  }

  if (url.pathname === '/api/projects') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(loadProjects()));
  }

  if (url.pathname === '/api/status') {
    const projects = loadProjects();
    const out = [];
    for (let i = 0; i < projects.length; i += 12) {
      out.push(...await Promise.all(projects.slice(i, i + 12).map(probe)));
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(out));
  }

  // Reverse proxy: /site/<slug>/<path> → project.target/<path> with the unified
  // admin credential injected + anti-framing headers stripped. Only known slugs.
  if (url.pathname.startsWith('/site/')) {
    const rest = url.pathname.slice('/site/'.length);
    const slash = rest.indexOf('/');
    const slug = slash < 0 ? rest : rest.slice(0, slash);
    if (slash < 0) { res.writeHead(302, { Location: `/site/${slug}/` }); return res.end(); }
    const p = bySlug().get(slug);
    if (!p) { res.writeHead(404); return res.end('unknown project'); }
    let t; try { t = parseTarget(p.target); } catch { res.writeHead(502); return res.end('bad target'); }
    const upstreamPath = rest.slice(slash) + url.search;
    const headers = { ...req.headers, host: `${t.host}:${t.port}`, authorization: UPSTREAM_AUTH };
    delete headers.connection;
    const opts = { host: t.host, port: t.port, method: req.method, path: upstreamPath, headers, timeout: 20000 };
    if (t.secure) opts.rejectUnauthorized = true;
    const preq = t.mod.request(opts, (pres) => {
      const h = { ...pres.headers };
      delete h['x-frame-options'];
      delete h['content-security-policy'];
      delete h['strict-transport-security'];
      delete h['referrer-policy'];   // keep the Referer-based asset escape hatch alive
      delete h.connection;
      // Rewrite same-target redirects back through /site/<slug>/ so navigation stays in-panel.
      if (h.location) {
        try {
          const loc = new URL(h.location, `${t.secure ? 'https' : 'http'}://${t.host}:${t.port}`);
          if (loc.hostname === t.host) h.location = `/site/${slug}${loc.pathname}${loc.search}`;
        } catch {}
      }
      if (h['set-cookie']) {
        h['set-cookie'] = h['set-cookie'].map((c) => c
          .replace(/;\s*Domain=[^;]+/gi, '')
          .replace(/;\s*Secure/gi, '')
          .replace(/;\s*Path=\/([^;]*)/i, `; Path=/site/${slug}/$1`));
      }
      res.writeHead(pres.statusCode, h);
      pres.pipe(res);
    });
    preq.on('timeout', () => preq.destroy(new Error('upstream timeout')));
    preq.on('error', (e) => { if (res.writableEnded) return; if (!res.headersSent) res.writeHead(502); res.end('proxy error: ' + e.message); });
    req.pipe(preq);
    return;
  }

  let file = url.pathname === '/' ? '/index.html' : url.pathname;
  const fp = path.join(ROOT, 'public', path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
  if (!fp.startsWith(path.join(ROOT, 'public'))) { res.writeHead(403); return res.end(); }

  // Root-relative asset escape hatch: a proxied page requesting "/app.js" lands
  // here; if the Referer is inside /site/<slug>/, bounce it back into the proxy.
  if (!fs.existsSync(fp)) {
    const m = (req.headers.referer || '').match(/\/site\/([^/]+)\//);
    if (m && bySlug().has(m[1])) {
      res.writeHead(302, { Location: `/site/${m[1]}${url.pathname}${url.search}` });
      return res.end();
    }
  }

  fs.readFile(fp, (err, data) => {
    if (err) { res.writeHead(404); return res.end('not found'); }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
    res.end(data);
  });
});

const HOST = process.env.HOST || '127.0.0.1';
server.listen(PORT, HOST, () => console.log(`projects-agentabrams on http://${HOST}:${PORT}`));