[object Object]

← back to Agentabrams Viewer

viewer: credential-injecting reverse proxy so unified admin pw auto-applies to in-panel sites

02f58ea7d218462fd7c9c2d33589522907952aa0 · 2026-07-22 19:03:30 -0700 · Steve Abrams

- /site/<domain>/ proxies upstream with admin/DW2024! injected (browsers won't basic-auth cross-origin iframes)
- strips X-Frame-Options/CSP so authed sites render in the panel; TLS verify stays ON
- status probe now does a 2nd authed HEAD to flag a rejected pw (authFail) vs a healthy gate
- allowlisted to domains.json; localhost-only, still behind admin/DW2024!

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit 02f58ea7d218462fd7c9c2d33589522907952aa0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 19:03:30 2026 -0700

    viewer: credential-injecting reverse proxy so unified admin pw auto-applies to in-panel sites
    
    - /site/<domain>/ proxies upstream with admin/DW2024! injected (browsers won't basic-auth cross-origin iframes)
    - strips X-Frame-Options/CSP so authed sites render in the panel; TLS verify stays ON
    - status probe now does a 2nd authed HEAD to flag a rejected pw (authFail) vs a healthy gate
    - allowlisted to domains.json; localhost-only, still behind admin/DW2024!
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 public/index.html | 26 ++++++----------
 server.js         | 91 +++++++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 87 insertions(+), 30 deletions(-)

diff --git a/public/index.html b/public/index.html
index 49f58bb..c12afc6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -47,7 +47,7 @@
       <input id="q" type="search" placeholder="Filter domains…" autocomplete="off">
     </header>
     <div id="list"></div>
-    <div id="foot">click a domain → loads in the right panel · dot: <span style="color:#3fbf6f">up</span> / <span style="color:#d9a53a">auth-gated</span> / <span style="color:#d95555">down</span></div>
+    <div id="foot">click a domain → loads in the right panel (unified admin pw auto-applied) · dot: <span style="color:#3fbf6f">up</span> / <span style="color:#d9a53a">auth-gated</span> / <span style="color:#d95555">down or pw rejected</span></div>
   </div>
   <div id="main">
     <div id="bar">
@@ -57,7 +57,7 @@
       <button id="newtab" title="Open in a new browser tab">↗ New Tab</button>
     </div>
     <div id="empty"><div class="big">🌐</div><div>Select a domain on the left to view the live site here.</div></div>
-    <iframe id="frame" style="display:none" referrerpolicy="no-referrer"></iframe>
+    <iframe id="frame" style="display:none"></iframe>
   </div>
 
 <script>
@@ -75,7 +75,7 @@ function render() {
     const s = status[d];
     const row = document.createElement('div');
     row.className = 'item' + (d === current ? ' active' : '');
-    const dotCls = !s ? '' : s.status === 401 ? 'auth' : s.up ? 'up' : 'down';
+    const dotCls = !s ? '' : s.authFail ? 'down' : s.gated ? 'auth' : s.up ? 'up' : 'down';
     const label = f ? d.replace(f, `<b>${f}</b>`) : d;
     row.innerHTML = `<span class="dot ${dotCls}"></span><span class="name">${label}</span>` +
       (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : '');
@@ -88,21 +88,15 @@ function select(d) {
   current = d;
   localStorage.setItem('aav-current', d);
   cur.innerHTML = `<a href="https://${d}" target="_blank" rel="noopener noreferrer">${d}</a>`;
-  const s = status[d];
-  if (s && s.framable === false) {
-    frame.style.display = 'none'; frame.src = 'about:blank';
-    empty.style.display = 'flex';
-    empty.innerHTML = `<div class="big">🚫</div><div><b>${d}</b> sends X-Frame-Options and refuses to render inside a panel.</div>` +
-      `<button onclick="window.open('https://${d}','_blank','noopener')">↗ Open in New Tab</button>`;
-  } else {
-    empty.style.display = 'none';
-    frame.style.display = 'block';
-    frame.src = 'https://' + d;
-  }
+  // Load through the server-side proxy — it injects the unified admin credential
+  // and strips X-Frame-Options, so every site renders in-panel without prompting.
+  empty.style.display = 'none';
+  frame.style.display = 'block';
+  frame.src = '/site/' + d + '/';
   render();
 }
 
-document.getElementById('reload').onclick = () => { if (current) frame.src = 'https://' + current; };
+document.getElementById('reload').onclick = () => { if (current) frame.src = '/site/' + current + '/'; };
 document.getElementById('newtab').onclick = () => { if (current) window.open('https://' + current, '_blank', 'noopener'); };
 q.oninput = render;
 
@@ -113,8 +107,6 @@ q.oninput = render;
   // Health dots load after the list — probes take a few seconds for 47 domains.
   const st = await (await fetch('/api/status')).json();
   for (const s of st) status[s.domain] = s;
-  // Re-select so a frame-blocked domain picked before probes finished swaps to the fallback card.
-  if (current && status[current] && status[current].framable === false) select(current);
   render();
 })();
 </script>
diff --git a/server.js b/server.js
index 013e3cf..6178306 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,9 @@
 #!/usr/bin/env node
-// agentabrams-viewer — left-panel domain list, right-panel iframe of the live site.
+// 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');
@@ -9,6 +12,8 @@ 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' };
 
@@ -17,25 +22,37 @@ function loadDomains() {
 }
 
 // Server-side health probe — the browser can't check cross-origin status itself.
-// 401 counts as UP (basic-auth gate is the healthy state for several of these sites).
-function probe(domain) {
+// Gated sites get a second HEAD with the unified credential so a dead password
+// (authFail) is distinguishable from a healthy auth gate.
+function head(domain, withAuth) {
   return new Promise((resolve) => {
-    const started = Date.now();
-    const req = https.request({ host: domain, method: 'HEAD', path: '/', timeout: 6000, rejectUnauthorized: false }, (res) => {
+    const headers = withAuth ? { authorization: UPSTREAM_AUTH } : {};
+    const req = https.request({ host: domain, method: 'HEAD', path: '/', headers, timeout: 6000, rejectUnauthorized: true }, (res) => {
       res.resume();
-      resolve({ domain, status: res.statusCode, ms: Date.now() - started, up: res.statusCode < 500, framable: !frameBlocked(res.headers) });
+      resolve(res.statusCode);
     });
-    req.on('timeout', () => { req.destroy(); resolve({ domain, status: 0, ms: Date.now() - started, up: false, framable: true }); });
-    req.on('error', () => resolve({ domain, status: 0, ms: Date.now() - started, up: false, framable: true }));
+    req.on('timeout', () => { req.destroy(); resolve(0); });
+    req.on('error', () => resolve(0));
     req.end();
   });
 }
 
-function frameBlocked(headers) {
-  const xfo = (headers['x-frame-options'] || '').toLowerCase();
-  if (xfo.includes('deny') || xfo.includes('sameorigin')) return true;
-  const csp = (headers['content-security-policy'] || '').toLowerCase();
-  return /frame-ancestors\s+(?!.*agentabrams)('none'|'self')/.test(csp);
+async function probe(domain) {
+  const started = Date.now();
+  const plain = await head(domain, false);
+  let gated = plain === 401, authFail = false, effective = plain;
+  if (gated) {
+    effective = await head(domain, true);
+    authFail = effective === 401;
+  }
+  return {
+    domain,
+    status: plain,
+    ms: Date.now() - started,
+    gated,
+    authFail,
+    up: effective !== 0 && effective !== 401 && effective < 500,
+  };
 }
 
 const server = http.createServer(async (req, res) => {
@@ -64,9 +81,57 @@ const server = http.createServer(async (req, res) => {
     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(); }
+    if (!loadDomains().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'];
+      delete h.connection;
+      if (h.location) {
+        try {
+          const loc = new URL(h.location, `https://${domain}`);
+          if (loadDomains().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) =>
+          c.replace(/;\s*Domain=[^;]+/gi, '').replace(/;\s*Path=\//i, `; Path=/site/${domain}/`));
+      }
+      res.writeHead(pres.statusCode, h);
+      pres.pipe(res);
+    });
+    preq.on('timeout', () => preq.destroy(new Error('upstream timeout')));
+    preq.on('error', (e) => { 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' });

← 21d5179 agentabrams domain viewer: left-rail list of all 47 *.agenta  ·  back to Agentabrams Viewer  ·  fix SSO cookie rewrite in site proxy: fleet-wide (Domain=) c 8ef170b →