← 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}`));