← back to Agentabrams Viewer

server.js

194 lines

#!/usr/bin/env node
// agentabrams-viewer — left-panel domain list, right-panel view of the live site.
// Zero-dependency Node http server. Basic auth admin/DW2024! (internal-viewer standard).
// The right panel loads /site/<domain>/… — a reverse proxy that injects the unified
// admin credential upstream (browsers refuse to basic-auth cross-origin iframes) and
// strips X-Frame-Options/CSP so every fleet site renders inside the panel.
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 9783;
const ROOT = __dirname;
const AUTH = 'Basic ' + Buffer.from(process.env.BASIC_AUTH || 'admin:DW2024!').toString('base64');
// Credential injected into upstream fleet sites (the unified admin pw).
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' };

function loadDomains() {
  try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'domains.json'), 'utf8')); }
  catch (e) { console.error('loadDomains failed:', e.message); return []; }
}

// Server-side health probe — the browser can't check cross-origin status itself.
// One request (HEAD, or GET when we need the body to sniff a login form). Returns
// { status, location, body } — status 0 means unreachable/timeout.
function fetch1(host, pathStr, withAuth, method) {
  return new Promise((resolve) => {
    const headers = withAuth ? { authorization: UPSTREAM_AUTH } : {};
    const req = https.request({ host, method, path: pathStr, headers, timeout: 6000, rejectUnauthorized: true }, (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();
  });
}

// A URL path or hostname that means "you're being sent to a login screen".
const AUTH_PATH_RE = /\/(login|signin|sign-in|auth|sso|oauth|account\/login|users\/sign_in)/i;
const AUTH_HOST_RE = /^(auth|login|sso|accounts?)\./i;
// A password input in the served HTML = the page itself gates (app-level login).
const PW_FIELD_RE  = /<input[^>]+type=["']?password|name=["']?(password|passwd|pwd)["']?[^>]*>/i;

// Classify how a domain is protected by walking its redirect chain the way a
// browser would. authType: 'basic' (HTTP 401 un/pw wall) | 'login' (redirect to
// an auth endpoint or a password field in the page) | 'open' (no auth found) |
// 'down' (unreachable). This is deliberately broader than "status === 401": a
// 302→/login and a 200 login page are BOTH protected, just not via Basic Auth.
async function probe(domain) {
  const started = Date.now();
  let host = domain, pathStr = '/', status = 0, firstStatus = null;
  let gated = false, authFail = false, authType = 'open';

  for (let hop = 0; hop < 5; hop++) {
    const r = await fetch1(host, pathStr, false, 'HEAD');
    if (firstStatus === null) firstStatus = r.status;
    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, `https://${host}`); } catch { break; }
      if (AUTH_PATH_RE.test(loc.pathname) || AUTH_HOST_RE.test(loc.host)) { gated = true; authType = 'login'; break; }
      host = loc.host; pathStr = loc.pathname + loc.search;
      continue;
    }
    break; // terminal non-redirect status (2xx/4xx/5xx other than 401)
  }

  // Basic-Auth gate: re-probe WITH the unified credential so a dead password
  // (authFail) is distinguishable from a healthy gate.
  if (authType === 'basic') {
    const eff = await fetch1(domain, '/', true, 'HEAD');
    authFail = eff.status === 401;
  }

  // Terminal 200 with no gate yet → pull the body once and sniff for a login form.
  if (!gated && status === 200) {
    const g = await fetch1(host, 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 { domain, status: firstStatus, finalStatus: status, ms: Date.now() - started, gated, 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="agentabrams-viewer"' });
    return res.end('auth required');
  }

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

  if (url.pathname === '/api/status') {
    const domains = loadDomains();
    // Probe in batches of 12 so 47 HEADs don't stampede.
    const out = [];
    for (let i = 0; i < domains.length; i += 12) {
      out.push(...await Promise.all(domains.slice(i, i + 12).map(probe)));
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(out));
  }

  // Reverse proxy: /site/<domain>/<path> → https://<domain>/<path> with the
  // unified admin credential injected. Only domains in domains.json are proxied.
  if (url.pathname.startsWith('/site/')) {
    const rest = url.pathname.slice('/site/'.length);
    const slash = rest.indexOf('/');
    const domain = slash < 0 ? rest : rest.slice(0, slash);
    if (slash < 0) { res.writeHead(302, { Location: `/site/${domain}/` }); return res.end(); }
    const domains = loadDomains();
    if (!domains.includes(domain)) { res.writeHead(404); return res.end('unknown domain'); }
    const upstreamPath = rest.slice(slash) + url.search;
    const headers = { ...req.headers, host: domain, authorization: UPSTREAM_AUTH };
    delete headers.connection;
    const preq = https.request({ host: domain, method: req.method, path: upstreamPath, headers, timeout: 20000, rejectUnauthorized: true }, (pres) => {
      const h = { ...pres.headers };
      // The whole point of the proxy: let everything render in the panel.
      delete h['x-frame-options'];
      delete h['content-security-policy'];
      delete h['strict-transport-security'];
      // The root-relative asset escape hatch below routes by Referer; an upstream
      // no-referrer policy would blind it and every /assets/* request would 404.
      delete h['referrer-policy'];
      delete h.connection;
      if (h.location) {
        try {
          const loc = new URL(h.location, `https://${domain}`);
          if (domains.includes(loc.host)) h.location = `/site/${loc.host}${loc.pathname}${loc.search}`;
        } catch {}
      }
      if (h['set-cookie']) {
        h['set-cookie'] = h['set-cookie'].map((c) => {
          // A Domain= attribute means the cookie is cross-subdomain (e.g. the
          // aafleet SSO cookie, Domain=.agentabrams.com) — scope it to /site/ so
          // it follows the user across every proxied domain, not just the setter.
          const fleetWide = /;\s*Domain=/i.test(c);
          return c
            .replace(/;\s*Domain=[^;]+/gi, '')
            // Viewer is served over plain http on loopback; Safari drops Secure cookies there.
            .replace(/;\s*Secure/gi, '')
            .replace(/;\s*Path=\/([^;]*)/i, fleetWide ? '; Path=/site/' : `; Path=/site/${domain}/$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 escape hatch: a proxied page requesting "/app.js" lands here.
  // If the request came from inside /site/<domain>/, bounce it back into the proxy.
  if (!fs.existsSync(fp)) {
    const m = (req.headers.referer || '').match(/\/site\/([^/]+)\//);
    if (m && loadDomains().includes(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);
  });
});

// Bind loopback by default so a public deploy is reachable ONLY via the HTTPS
// nginx vhost (never the plaintext high port). Override with HOST=0.0.0.0 if needed.
const HOST = process.env.HOST || '127.0.0.1';
server.listen(PORT, HOST, () => console.log(`agentabrams-viewer on http://${HOST}:${PORT}`));