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