fix: BUG-33 CPU threshold, TASK-27 tab icons, TASK-36 iframe errors

- BUG-33: CPU load alert threshold increased from 2x to 4x core count
  (8→16 on 4-core machine) to reduce false alerts during container ops
- TASK-27: Launch buttons for new-tab apps now show external link icon
  (BTCPay, Grafana, PhotoPrism, Portainer, OnlyOffice, etc.)
- TASK-36: Iframe error screen now distinguishes between X-Frame-Options
  blocked vs container not reachable, with appropriate messaging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 19:24:52 +00:00
co-authored by Claude Opus 4.6
parent 1ffc377a9c
commit 25ad68ac4c
182 changed files with 969 additions and 36407 deletions
+642 -66
View File
@@ -6,33 +6,39 @@
<title>Archipelago — Architecture Review & Learning Guide</title>
<style>
:root {
--bg: #0a0a0f;
--surface: #12121a;
--surface-2: #1a1a26;
--border: rgba(255,255,255,0.08);
--border-bright: rgba(255,255,255,0.15);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.55);
--bg: #000000;
--glass-card: rgba(0, 0, 0, 0.65);
--glass-dark: rgba(0, 0, 0, 0.35);
--glass-darker: rgba(0, 0, 0, 0.6);
--glass-border: rgba(255, 255, 255, 0.18);
--glass-highlight: rgba(255, 255, 255, 0.22);
--glass-blur: 18px;
--glass-blur-strong: 24px;
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
--text: rgba(255, 255, 255, 0.9);
--text-muted: rgba(255, 255, 255, 0.6);
--accent: #fb923c;
--accent-dim: rgba(251,146,60,0.15);
--accent-dim: rgba(251, 146, 60, 0.15);
--green: #4ade80;
--green-dim: rgba(74,222,128,0.12);
--green-dim: rgba(74, 222, 128, 0.15);
--red: #ef4444;
--red-dim: rgba(239,68,68,0.12);
--red-dim: rgba(239, 68, 68, 0.12);
--blue: #3b82f6;
--blue-dim: rgba(59,130,246,0.12);
--blue-dim: rgba(59, 130, 246, 0.12);
--yellow: #facc15;
--yellow-dim: rgba(250,204,21,0.12);
--yellow-dim: rgba(250, 204, 21, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167,139,250,0.12);
--glass: rgba(255,255,255,0.04);
--radius: 12px;
--purple-dim: rgba(167, 139, 250, 0.12);
--radius: 16px;
--radius-sm: 12px;
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
@@ -46,22 +52,25 @@
left: 0;
width: 280px;
height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border-right: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass);
overflow-y: auto;
padding: 24px 0;
z-index: 100;
scrollbar-width: thin;
scrollbar-color: var(--border-bright) transparent;
scrollbar-color: rgba(255,255,255,0.15) transparent;
}
nav .logo {
padding: 0 24px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
nav .logo h1 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 18px;
font-weight: 700;
color: var(--accent);
@@ -89,13 +98,13 @@
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
transition: all 0.2s;
transition: all var(--transition);
border-left: 2px solid transparent;
}
nav a:hover, nav a.active {
color: var(--text);
background: var(--glass);
background: rgba(255, 255, 255, 0.06);
border-left-color: var(--accent);
}
@@ -107,18 +116,19 @@
}
h2 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 28px;
font-weight: 700;
margin: 64px 0 8px;
padding-top: 24px;
color: var(--text);
letter-spacing: -0.02em;
border-top: 1px solid var(--border);
}
h2:first-of-type { border-top: none; margin-top: 0; }
h2:first-of-type { margin-top: 0; }
h3 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 20px;
font-weight: 600;
margin: 40px 0 12px;
@@ -148,6 +158,7 @@
}
.hero h1 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 42px;
font-weight: 800;
background: linear-gradient(135deg, var(--accent), #f59e0b);
@@ -177,14 +188,20 @@
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border-bright);
background: var(--glass-dark);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
color: var(--text-muted);
}
/* ─── Cards ─── */
/* ─── Cards (Glass) ─── */
.card {
background: var(--surface);
border: 1px solid var(--border);
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 24px;
margin: 16px 0;
@@ -198,10 +215,19 @@
}
.card-sm {
background: var(--surface);
border: 1px solid var(--border);
background: var(--glass-darker);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 16px 20px;
transition: transform var(--transition), box-shadow var(--transition);
}
.card-sm:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
@@ -228,50 +254,62 @@
/* ─── Tables ─── */
table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
margin: 16px 0;
font-size: 14px;
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-glass);
}
th {
text-align: left;
padding: 10px 14px;
background: var(--surface-2);
background: rgba(0, 0, 0, 0.4);
color: var(--text-muted);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--glass-border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
tr:hover td { background: var(--glass); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255, 255, 255, 0.04); }
/* ─── Code ─── */
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
background: var(--surface-2);
background: rgba(0, 0, 0, 0.4);
padding: 2px 6px;
border-radius: 4px;
color: var(--accent);
}
pre {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 20px;
overflow-x: auto;
margin: 16px 0;
font-size: 13px;
line-height: 1.6;
box-shadow: var(--shadow-glass);
}
pre code {
@@ -287,13 +325,16 @@
/* ─── Diagrams (ASCII art in pre) ─── */
.diagram {
background: var(--surface);
border: 1px solid var(--border-bright);
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 24px;
margin: 20px 0;
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
@@ -307,18 +348,23 @@
/* ─── Callouts ─── */
.callout {
border-radius: var(--radius);
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 16px 20px;
margin: 16px 0;
font-size: 14px;
border-left: 3px solid;
box-shadow: var(--shadow-glass);
}
.callout-info { background: var(--blue-dim); border-color: var(--blue); }
.callout-warn { background: var(--yellow-dim); border-color: var(--yellow); }
.callout-danger { background: var(--red-dim); border-color: var(--red); }
.callout-success { background: var(--green-dim); border-color: var(--green); }
.callout-learn { background: var(--purple-dim); border-color: var(--purple); }
.callout-info { border-color: var(--blue); }
.callout-warn { border-color: var(--yellow); }
.callout-danger { border-color: var(--red); }
.callout-success { border-color: var(--green); }
.callout-learn { border-color: var(--purple); }
.callout strong { display: block; margin-bottom: 4px; }
@@ -331,14 +377,21 @@
}
.score-card {
background: var(--surface);
border: 1px solid var(--border);
background: var(--glass-darker);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 16px;
text-align: center;
transition: transform var(--transition);
}
.score-card:hover { transform: translateY(-2px); }
.score-card .score {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 32px;
font-weight: 800;
margin: 4px 0;
@@ -362,11 +415,14 @@
/* ─── Analogy boxes ─── */
.analogy {
background: var(--purple-dim);
border: 1px solid rgba(167,139,250,0.2);
border-radius: var(--radius);
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid rgba(167, 139, 250, 0.25);
border-radius: var(--radius-sm);
padding: 20px;
margin: 16px 0;
box-shadow: var(--shadow-glass);
}
.analogy::before {
@@ -397,7 +453,7 @@
/* ─── Separator ─── */
hr {
border: none;
border-top: 1px solid var(--border);
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin: 40px 0;
}
@@ -425,6 +481,139 @@
.flow-step .content { flex: 1; }
.flow-step .content strong { color: var(--accent); }
.flow-step .content p { margin: 0; font-size: 14px; }
/* ─── Comparison helpers ─── */
.check { color: var(--green); font-weight: 700; }
.cross { color: var(--red); opacity: 0.7; }
.partial { color: var(--yellow); }
td.archy-col { background: var(--accent-dim); }
th.archy-col { color: var(--accent); background: var(--accent-dim); }
.card-sm .item-list { margin: 0; padding-left: 16px; }
.card-sm .item-list li { font-size: 13px; margin: 4px 0; color: var(--text-muted); }
/* ─── Theme toggle ─── */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 16px 16px 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
}
.theme-toggle .icon { font-size: 14px; }
/* ─── Light mode ─── */
[data-theme="light"] {
--bg: #FAFAFA;
--glass-card: rgba(255, 255, 255, 0.7);
--glass-dark: rgba(255, 255, 255, 0.5);
--glass-darker: rgba(255, 255, 255, 0.65);
--glass-border: rgba(0, 0, 0, 0.1);
--glass-highlight: rgba(255, 255, 255, 0.8);
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.08);
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.9);
--text: rgba(0, 0, 0, 0.88);
--text-muted: rgba(0, 0, 0, 0.5);
--accent: #ea7c1f;
--accent-dim: rgba(234, 124, 31, 0.1);
--green: #16a34a;
--green-dim: rgba(22, 163, 74, 0.1);
--red: #dc2626;
--red-dim: rgba(220, 38, 38, 0.08);
--blue: #2563eb;
--blue-dim: rgba(37, 99, 235, 0.08);
--yellow: #ca8a04;
--yellow-dim: rgba(202, 138, 4, 0.08);
--purple: #7c3aed;
--purple-dim: rgba(124, 58, 237, 0.08);
}
[data-theme="light"] body { background: var(--bg); }
[data-theme="light"] nav {
background: rgba(255, 255, 255, 0.8);
border-right-color: rgba(0, 0, 0, 0.08);
box-shadow: 0 0 24px rgba(0, 0, 0, 0.06);
}
[data-theme="light"] nav a:hover,
[data-theme="light"] nav a.active {
background: rgba(0, 0, 0, 0.04);
}
[data-theme="light"] th {
background: rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.55);
border-bottom-color: rgba(0, 0, 0, 0.08);
}
[data-theme="light"] td {
border-bottom-color: rgba(0, 0, 0, 0.06);
}
[data-theme="light"] tr:hover td {
background: rgba(0, 0, 0, 0.02);
}
[data-theme="light"] code {
background: rgba(0, 0, 0, 0.06);
color: #c2410c;
}
[data-theme="light"] .hero h1 {
background: linear-gradient(135deg, #c2410c, #ea580c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
[data-theme="light"] .hero .meta span {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.55);
}
[data-theme="light"] .analogy {
border-color: rgba(124, 58, 237, 0.2);
}
[data-theme="light"] .theme-toggle {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.08);
}
[data-theme="light"] .theme-toggle:hover {
background: rgba(0, 0, 0, 0.08);
}
[data-theme="light"] .diagram { color: rgba(0, 0, 0, 0.5); }
[data-theme="light"] hr { border-top-color: rgba(0, 0, 0, 0.08); }
[data-theme="light"] .score-card .score { filter: none; }
/* ─── Reduce motion ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
@@ -436,6 +625,11 @@
<p>Architecture Review & Guide</p>
</div>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark mode">
<span class="icon" id="theme-icon">&#9790;</span>
<span id="theme-label">Light mode</span>
</button>
<div class="nav-section">Overview</div>
<a href="#what-is-it">What Is Archipelago?</a>
<a href="#big-picture">The Big Picture</a>
@@ -794,25 +988,375 @@ Body: { <span class="string">"method"</span>: <span class="string">"package.inst
<h3 id="nginx">Layer 4: Nginx (The Traffic Cop)</h3>
<p><strong>Nginx</strong> (pronounced "engine-X") is a web server that sits between the internet and everything else. Every single request goes through it first.</p>
<p><strong>Nginx</strong> (pronounced "engine-X") is a web server that sits between the internet and everything else. Every single request goes through it first. Archipelago's nginx config is ~1,100 lines — one of the most complex parts of the system.</p>
<div class="analogy">
<p>Nginx is like the receptionist at a hospital. You walk in and say what you need. "I need the API" — they send you to the Rust backend. "I need the Bitcoin app" — they send you to the Bitcoin container. "I need the website" — they hand you the static files. Without the receptionist, you'd be wandering the hallways lost.</p>
</div>
<h4>How Nginx routes traffic</h4>
<!-- ─── Comparison: Why Nginx? ─── -->
<h4>Why Nginx? Comparing Reverse Proxies</h4>
<p>Every node OS needs a reverse proxy to route traffic. Here's how the major projects differ:</p>
<div class="card-grid">
<div class="card-sm" style="border-color: var(--accent); border-width: 2px;">
<h4>Nginx <span class="badge badge-accent">Archipelago</span></h4>
<p><span class="check">&#10003;</span> Battle-tested (30+ years)<br>
<span class="check">&#10003;</span> Sub-millisecond routing<br>
<span class="check">&#10003;</span> Fine-grained rate limiting<br>
<span class="check">&#10003;</span> sub_filter HTML rewriting<br>
<span class="check">&#10003;</span> Full CSP / HSTS control<br>
<span class="partial">~</span> Manual config (1,100 lines)<br>
<span class="cross">&#10007;</span> No auto-TLS (manual certs)</p>
</div>
<div class="card-sm">
<h4>Caddy <span class="badge badge-blue">Umbrel</span></h4>
<p><span class="check">&#10003;</span> Automatic HTTPS / Let's Encrypt<br>
<span class="check">&#10003;</span> Simple Caddyfile syntax<br>
<span class="check">&#10003;</span> Built-in HTTP/3 support<br>
<span class="cross">&#10007;</span> No sub_filter (needs plugins)<br>
<span class="cross">&#10007;</span> Higher memory footprint<br>
<span class="cross">&#10007;</span> Less granular rate limiting<br>
<span class="partial">~</span> Newer, smaller ecosystem</p>
</div>
<div class="card-sm">
<h4>Tor-only <span class="badge badge-purple">StartOS</span></h4>
<p><span class="check">&#10003;</span> Maximum privacy (no clearnet)<br>
<span class="check">&#10003;</span> No port forwarding needed<br>
<span class="check">&#10003;</span> Built-in NAT traversal<br>
<span class="cross">&#10007;</span> Slow (500ms3s latency)<br>
<span class="cross">&#10007;</span> No LAN access without config<br>
<span class="cross">&#10007;</span> Requires .onion browser support<br>
<span class="cross">&#10007;</span> No WebSocket over Tor (flaky)</p>
</div>
<div class="card-sm">
<h4>NixOS Module <span class="badge badge-green">Nix-Bitcoin</span></h4>
<p><span class="check">&#10003;</span> Declarative, reproducible<br>
<span class="check">&#10003;</span> Atomic rollbacks<br>
<span class="check">&#10003;</span> Any proxy (Nginx/Caddy/HAProxy)<br>
<span class="partial">~</span> Steep learning curve (Nix lang)<br>
<span class="cross">&#10007;</span> No web UI (CLI only)<br>
<span class="cross">&#10007;</span> Not beginner-friendly<br>
<span class="cross">&#10007;</span> Long rebuild times</p>
</div>
</div>
<div class="callout callout-info">
<strong>Archipelago's choice:</strong> Nginx gives the most control over security headers, rate limiting, and HTML rewriting (injecting Nostr provider scripts into app iframes). The tradeoff is a 1,100-line config instead of a 50-line Caddyfile — but for a Bitcoin node OS, that control is worth it.
</div>
<!-- ─── Full Comparison Table ─── -->
<h4>Head-to-Head: Architecture Decisions</h4>
<table>
<tr><th>URL Pattern</th><th>Goes To</th><th>Why</th></tr>
<tr><td><code>/rpc/v1</code></td><td>Rust backend (:5678)</td><td>All API calls</td></tr>
<tr><td><code>/health</code></td><td>Rust backend (:5678)</td><td>Health checks (no auth needed)</td></tr>
<tr><td><code>/app/bitcoin-ui/</code></td><td>Bitcoin container (:8334)</td><td>Bitcoin web interface</td></tr>
<tr><td><code>/app/mempool/</code></td><td>Mempool container (:4080)</td><td>Mempool explorer</td></tr>
<tr><td><code>/app/filebrowser/</code></td><td>FileBrowser container (:8083)</td><td>File manager</td></tr>
<tr><td><code>/aiui/</code></td><td>Static files on disk</td><td>AI chat interface</td></tr>
<tr><td><code>/</code> (everything else)</td><td>Vue.js SPA files on disk</td><td>The main dashboard</td></tr>
<tr>
<th>Feature</th>
<th class="archy-col">Archipelago</th>
<th>Umbrel</th>
<th>StartOS</th>
<th>Nix-Bitcoin</th>
<th>RaspiBlitz</th>
</tr>
<tr>
<td>Reverse Proxy</td>
<td class="archy-col"><strong>Nginx</strong></td>
<td>Caddy</td>
<td>Tor hidden svc</td>
<td>Nginx (Nix module)</td>
<td>Nginx</td>
</tr>
<tr>
<td>Backend</td>
<td class="archy-col"><strong>Rust</strong></td>
<td>Node.js + Go</td>
<td>Rust (startos)</td>
<td>Shell/Nix</td>
<td>Shell scripts</td>
</tr>
<tr>
<td>Containers</td>
<td class="archy-col"><strong>Rootless Podman</strong></td>
<td>Docker (root)</td>
<td>Docker (root)</td>
<td>None (native pkgs)</td>
<td>Docker (root)</td>
</tr>
<tr>
<td>TLS/HTTPS</td>
<td class="archy-col"><strong>Self-signed + HSTS</strong></td>
<td>Auto (Let's Encrypt)</td>
<td>Tor-only (no TLS)</td>
<td>Let's Encrypt</td>
<td>Self-signed</td>
</tr>
<tr>
<td>Rate Limiting</td>
<td class="archy-col"><strong>Dual-zone (RPC 20r/s + Auth 3r/s)</strong></td>
<td>None</td>
<td>None</td>
<td>Optional (manual)</td>
<td>None</td>
</tr>
<tr>
<td>Security Headers</td>
<td class="archy-col"><strong>Full CSP + HSTS + Permissions</strong></td>
<td>Basic</td>
<td>N/A (Tor)</td>
<td>Configurable</td>
<td>Minimal</td>
</tr>
<tr>
<td>App Isolation</td>
<td class="archy-col"><strong>Cap-drop, readonly root, non-root UID</strong></td>
<td>Docker defaults</td>
<td>Docker + sandboxing</td>
<td>systemd sandboxing</td>
<td>Docker defaults</td>
</tr>
<tr>
<td>LAN + Remote</td>
<td class="archy-col"><strong>LAN + Tailscale + Tor</strong></td>
<td>LAN + Tor + Tailscale</td>
<td>Tor-only (LAN optional)</td>
<td>LAN + WireGuard</td>
<td>LAN + Tor</td>
</tr>
<tr>
<td>WebSocket</td>
<td class="archy-col"><strong>Native (24h timeout)</strong></td>
<td>Polling + WS</td>
<td>SSE over Tor</td>
<td>N/A</td>
<td>Polling</td>
</tr>
<tr>
<td>App UI Injection</td>
<td class="archy-col"><strong>sub_filter (Nostr NIP-07)</strong></td>
<td>None</td>
<td>None</td>
<td>N/A</td>
<td>None</td>
</tr>
</table>
<p>Nginx also handles <strong>rate limiting</strong> (blocking too many requests), <strong>security headers</strong> (preventing attacks), and <strong>WebSocket upgrades</strong> (for real-time updates).</p>
<!-- ─── Routing Map ─── -->
<h4>How Nginx Routes Traffic</h4>
<p>The config defines 30+ <code>location</code> blocks across HTTP (port 80) and HTTPS (port 443). Here are the major routing categories:</p>
<div class="card">
<h4>Backend & API Routes</h4>
<table>
<tr><th>URL Pattern</th><th>Backend</th><th>Rate Limit</th><th>Timeout</th><th>Purpose</th></tr>
<tr><td><code>/rpc/</code></td><td>:5678</td><td>20r/s (burst 40)</td><td>600s</td><td>All RPC API calls (1MB body limit)</td></tr>
<tr><td><code>/ws</code></td><td>:5678</td><td></td><td>86,400s (24h)</td><td>WebSocket — real-time state updates</td></tr>
<tr><td><code>/health</code></td><td>:5678</td><td></td><td>default</td><td>Health check (no auth)</td></tr>
<tr><td><code>/archipelago/</code></td><td>:5678</td><td></td><td>default</td><td>System endpoints</td></tr>
<tr><td><code>/content</code></td><td>:5678</td><td></td><td>default</td><td>Peer content sharing</td></tr>
<tr><td><code>/dwn</code></td><td>:5678</td><td></td><td>default</td><td>Decentralized Web Node</td></tr>
<tr><td><code>/electrs-status</code></td><td>:5678</td><td></td><td>default</td><td>Electrum sync status (CORS enabled)</td></tr>
<tr><td><code>/lnd-connect-info</code></td><td>:5678</td><td></td><td>default</td><td>LND connection URI (CORS enabled)</td></tr>
</table>
</div>
<div class="card">
<h4>App Proxies — 24 Container Apps</h4>
<p>Every <code>/app/{id}/</code> route proxies into a container. All share a common pattern: strip the upstream <code>X-Frame-Options</code>, set <code>SAMEORIGIN</code>, inject the Nostr provider script, and forward real IP headers.</p>
<table>
<tr><th>App</th><th>Port</th><th>Special Config</th></tr>
<tr><td><code>bitcoin-ui</code></td><td>8334</td><td></td></tr>
<tr><td><code>mempool</code></td><td>4080</td><td>300s timeouts</td></tr>
<tr><td><code>lnd</code></td><td>8081</td><td>300s timeouts</td></tr>
<tr><td><code>electrumx</code></td><td>50002</td><td></td></tr>
<tr><td><code>btcpay</code></td><td>23000</td><td></td></tr>
<tr><td><code>fedimint</code></td><td>8175</td><td>300s timeouts</td></tr>
<tr><td><code>fedimint-gateway</code></td><td>8176</td><td>300s timeouts</td></tr>
<tr><td><code>filebrowser</code></td><td>8083</td><td>10GB uploads, path traversal blocking</td></tr>
<tr><td><code>nextcloud</code></td><td>8085</td><td>300s timeouts</td></tr>
<tr><td><code>vaultwarden</code></td><td>8082</td><td></td></tr>
<tr><td><code>immich</code></td><td>2283</td><td>300s timeouts</td></tr>
<tr><td><code>jellyfin</code></td><td>8096</td><td></td></tr>
<tr><td><code>grafana</code></td><td>3000</td><td></td></tr>
<tr><td><code>portainer</code></td><td>9000</td><td></td></tr>
<tr><td><code>uptime-kuma</code></td><td>3001</td><td></td></tr>
<tr><td><code>searxng</code></td><td>8888</td><td></td></tr>
<tr><td><code>ollama</code></td><td>11434</td><td></td></tr>
<tr><td><code>indeedhub</code></td><td>7777</td><td>URL rewriting, WS, 30-day asset cache</td></tr>
<tr><td><code>homeassistant</code></td><td>8123</td><td>86,400s timeout (persistent)</td></tr>
<tr><td><code>penpot</code></td><td>9001</td><td>300s timeouts</td></tr>
<tr><td><code>photoprism</code></td><td>2342</td><td></td></tr>
<tr><td><code>onlyoffice</code></td><td>8044</td><td></td></tr>
<tr><td><code>endurain</code></td><td>8080</td><td></td></tr>
<tr><td><code>nginx-proxy-manager</code></td><td>8181</td><td></td></tr>
</table>
</div>
<div class="card">
<h4>AIUI Routes (AI Chat Interface)</h4>
<p>The AI chat UI has its own set of proxied API backends — all require a valid session cookie or return <code>401</code>.</p>
<table>
<tr><th>URL Pattern</th><th>Backend</th><th>Timeout</th><th>Purpose</th></tr>
<tr><td><code>/aiui/</code></td><td>Static files</td><td></td><td>Chat UI (no-cache for HTML)</td></tr>
<tr><td><code>/aiui/api/claude/</code></td><td>:3142</td><td>300s read</td><td>Claude proxy (streaming, no buffering)</td></tr>
<tr><td><code>/aiui/api/ollama/</code></td><td>:11434</td><td>300s read</td><td>Local Ollama model (streaming)</td></tr>
<tr><td><code>/aiui/api/openrouter/</code></td><td>openrouter.ai</td><td>120s</td><td>External AI API (SSL passthrough)</td></tr>
<tr><td><code>/aiui/api/web-search</code></td><td>:8888</td><td>30s</td><td>SearXNG search (503 JSON on failure)</td></tr>
</table>
</div>
<!-- ─── Security Headers Comparison ─── -->
<h4>Security Headers — How Archipelago Compares</h4>
<p>Security headers tell the browser what's allowed and what isn't. Here's what each node OS sends:</p>
<table>
<tr>
<th>Header</th>
<th class="archy-col">Archipelago</th>
<th>Umbrel</th>
<th>StartOS</th>
<th>RaspiBlitz</th>
</tr>
<tr>
<td>Content-Security-Policy</td>
<td class="archy-col"><span class="badge badge-green">Full</span> self + WS + frame-src</td>
<td><span class="badge badge-yellow">Basic</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
</tr>
<tr>
<td>HSTS</td>
<td class="archy-col"><span class="badge badge-green">1 year</span> + includeSubDomains</td>
<td><span class="badge badge-green">Yes</span></td>
<td><span class="badge badge-red">N/A</span> (Tor)</td>
<td><span class="badge badge-red">No</span></td>
</tr>
<tr>
<td>X-Frame-Options</td>
<td class="archy-col"><span class="badge badge-green">SAMEORIGIN</span></td>
<td><span class="badge badge-yellow">Varies</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
</tr>
<tr>
<td>X-Content-Type-Options</td>
<td class="archy-col"><span class="badge badge-green">nosniff</span></td>
<td><span class="badge badge-green">nosniff</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
</tr>
<tr>
<td>Permissions-Policy</td>
<td class="archy-col"><span class="badge badge-green">All blocked</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
</tr>
<tr>
<td>Referrer-Policy</td>
<td class="archy-col"><span class="badge badge-green">strict-origin</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
</tr>
<tr>
<td>Rate Limiting</td>
<td class="archy-col"><span class="badge badge-green">Dual-zone</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
<td><span class="badge badge-red">None</span></td>
</tr>
</table>
<div class="callout callout-success">
<strong>Archipelago leads on security headers.</strong>
Most node OS projects ship with minimal or no HTTP security headers. Archipelago sets a full Content-Security-Policy, HSTS with 1-year max-age, Permissions-Policy blocking camera/microphone/geolocation/payment, and dual-zone rate limiting — defense-in-depth at the proxy layer.
</div>
<!-- ─── Unique Features ─── -->
<h4>Unique Nginx Features in Archipelago</h4>
<div class="card-grid">
<div class="card-sm">
<h4>Nostr NIP-07 Injection</h4>
<ul class="item-list">
<li>Every app proxy uses <code>sub_filter</code> to inject <code>nostr-provider.js</code> into <code>&lt;/head&gt;</code></li>
<li>Gives all container apps <code>window.nostr</code> for signing</li>
<li>No other node OS does this — unique to Archipelago</li>
<li><code>Accept-Encoding</code> disabled to enable text rewriting</li>
</ul>
</div>
<div class="card-sm">
<h4>Dual Rate Limit Zones</h4>
<ul class="item-list">
<li><strong>rpc zone:</strong> 20 req/s base, burst of 40 — for API calls</li>
<li><strong>auth zone:</strong> 3 req/s — for login/auth endpoints (brute-force protection)</li>
<li>Returns HTTP 429 on violation</li>
<li>Per-IP tracking with 10MB shared memory zone</li>
</ul>
</div>
<div class="card-sm">
<h4>External Site Proxying</h4>
<ul class="item-list">
<li><code>/ext/botfights/</code>, <code>/ext/484-kitchen/</code>, etc. proxy external HTTPS sites</li>
<li>Strips CORS/COEP/COOP headers for iframe embedding</li>
<li>Rewrites <code>href</code>/<code>src</code> attributes to rebase paths</li>
<li>Standalone proxy servers on ports 89018903</li>
</ul>
</div>
<div class="card-sm">
<h4>FileBrowser Security</h4>
<ul class="item-list">
<li>Path traversal blocked: <code>/\.\.</code> patterns return 403</li>
<li>10GB upload limit (<code>client_max_body_size 10G</code>)</li>
<li><code>proxy_request_buffering off</code> for streaming large uploads</li>
<li>Separate protection for <code>/api/resources/</code> and <code>/api/raw/</code> paths</li>
</ul>
</div>
<div class="card-sm">
<h4>SSL/TLS Configuration</h4>
<ul class="item-list">
<li>TLSv1.2 + TLSv1.3 only (no older protocols)</li>
<li>Modern cipher suite: ECDHE-ECDSA + ECDHE-RSA with AES-GCM</li>
<li>Self-signed certificate at <code>/etc/archipelago/ssl/</code></li>
<li>Dual-server setup: port 80 (HTTP) + port 443 (HTTPS)</li>
</ul>
</div>
<div class="card-sm">
<h4>IndeedhHub Complexity</h4>
<ul class="item-list">
<li>Most complex app proxy: URL rewriting, WebSocket, caching</li>
<li><code>_next/</code> assets cached 30 days with <code>immutable</code></li>
<li>WebSocket at <code>/app/indeedhub/ws/</code> with 24h timeout</li>
<li>Rewrites both single and double quoted <code>href</code>/<code>src</code></li>
</ul>
</div>
</div>
<!-- ─── Config Files Map ─── -->
<h4>Nginx Config File Map</h4>
<div class="card">
<table>
<tr><th>File</th><th>Lines</th><th>Purpose</th></tr>
<tr><td><code>image-recipe/configs/nginx-archipelago.conf</code></td><td>~1,100</td><td>Production config — HTTP + HTTPS servers, all routing</td></tr>
<tr><td><code>image-recipe/configs/snippets/archipelago-https-app-proxies.conf</code></td><td>~400</td><td>HTTPS app proxy blocks (included in main config)</td></tr>
<tr><td><code>image-recipe/configs/snippets/archipelago-pwa.conf</code></td><td>~30</td><td>PWA service worker and manifest caching</td></tr>
<tr><td><code>image-recipe/configs/external-app-proxies.conf</code></td><td>~200</td><td>External site reverse proxies (BotFights, 484 Kitchen)</td></tr>
<tr><td><code>neode-ui/docker/nginx.conf</code></td><td>~60</td><td>Dev Docker config (mock backend on :5959)</td></tr>
<tr><td><code>neode-ui/docker/nginx-demo.conf</code></td><td>~80</td><td>Demo mode config (no security, mock backend)</td></tr>
<tr><td><code>docker/bitcoin-ui/nginx.conf</code></td><td>~50</td><td>Bitcoin UI container — RPC proxy with CORS</td></tr>
<tr><td><code>docker/electrs-ui/nginx.conf</code></td><td>~30</td><td>Electrs UI container — status endpoint</td></tr>
<tr><td><code>docker/lnd-ui/nginx.conf</code></td><td>~30</td><td>LND UI container — connect info</td></tr>
<tr><td><code>indeedhub/nginx.conf</code></td><td>~200</td><td>IndeedhHub container — MinIO, API, relay, SPA</td></tr>
</table>
</div>
<div class="callout callout-learn">
<strong>Why so many nginx configs?</strong>
There are three layers of nginx: (1) the <strong>main server nginx</strong> that routes all traffic, (2) <strong>per-app container nginx</strong> configs inside some containers (bitcoin-ui, electrs-ui, lnd-ui, indeedhub) that serve their own SPAs and proxy to internal services, and (3) <strong>dev/demo nginx</strong> configs for local development. Changes to app routing require updating BOTH the main config AND the relevant container config.
</div>
<!-- ══════════════════════════════════════════════════════ -->
<h2 id="data-flow">How Data Flows Through the System</h2>
@@ -1524,5 +2068,37 @@ function updateActiveNav() {
window.addEventListener('scroll', updateActiveNav, { passive: true });
updateActiveNav();
</script>
<script>
function toggleTheme() {
const html = document.documentElement;
const isLight = html.getAttribute('data-theme') === 'light';
const next = isLight ? 'dark' : 'light';
html.setAttribute('data-theme', next);
localStorage.setItem('archy-review-theme', next);
updateToggleUI(next);
}
function updateToggleUI(theme) {
const icon = document.getElementById('theme-icon');
const label = document.getElementById('theme-label');
if (theme === 'light') {
icon.innerHTML = '&#9728;';
label.textContent = 'Dark mode';
} else {
icon.innerHTML = '&#9790;';
label.textContent = 'Light mode';
}
}
(function() {
const saved = localStorage.getItem('archy-review-theme');
const preferred = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
const theme = saved || preferred;
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
updateToggleUI(theme);
})();
</script>
</body>
</html>