[2026-06-04] eagle-dashboard: port 80 (pf redirect), memory tracking, fix port 3000→8880 (Virfield conflict)

This commit is contained in:
Alexey Martemyanov
2026-06-04 12:14:19 +06:00
parent 2aceaa79a9
commit 36fe77154b
+188 -25
View File
@@ -4,7 +4,9 @@
**Goal:** Replace `localhost` with a visual control panel that shows health, enables management (start/stop/restart) of all 25+ Eagle LaunchAgent services, and displays network security posture per service.
**Architecture:** FastAPI backend reads a YAML service registry and checks health via HTTP/port/process checks every 30s. HTMX + Tailwind CDN frontend with three tabs: **Services** (health cards + control), **Pages** (local page tester — URL input + iframe), **Files** (embedded FileBrowser). Origin-aware auth middleware: `127.0.0.1` → free access; external via cloudflared `dash.qentra.top` → Bearer token required. LaunchAgent on `:3000`. FileBrowser Docker container on `:8181`.
**Architecture:** FastAPI backend reads a YAML service registry and checks health + memory via HTTP/port/process checks every 30s. HTMX + Tailwind CDN frontend with three tabs: **Services** (health cards with memory usage + control), **Pages** (local page tester — URL input + iframe), **Files** (embedded FileBrowser). Origin-aware auth middleware: `127.0.0.1` → free access; external via cloudflared `dash.qentra.top` → Bearer token required.
Runs internally on `127.0.0.1:8880`. macOS `pf` anchor redirects `localhost:80``:8880` so it's accessible at plain `http://localhost` (replacing macOS default httpd). Cloudflared tunnel points directly to `:8880`. FileBrowser Docker container on `:8181`.
**Tech Stack:** Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx, python-dotenv
@@ -75,10 +77,10 @@ install:
pip install -r requirements.txt
dev:
uvicorn main:app --reload --host 127.0.0.1 --port 3000
uvicorn main:app --reload --host 127.0.0.1 --port 8880
prod:
uvicorn main:app --host 127.0.0.1 --port 3000 --workers 1
uvicorn main:app --host 127.0.0.1 --port 8880 --workers 1
deploy:
cp com.eagle.dashboard.plist ~/Library/LaunchAgents/
@@ -106,6 +108,95 @@ git init && git add . && git commit -m "feat: project bootstrap"
---
## Task 1b: Port 80 → 8880 via macOS pf Redirect
**Goal:** Make `http://localhost` (port 80) serve the dashboard without running Python as root. Uses macOS `pf` packet filter to NAT-redirect `127.0.0.1:80``127.0.0.1:8880`. Replaces (disables) the macOS built-in httpd.
**Files:**
- Create: `/etc/pf.anchors/eagle-localhost`
- Modify: `/etc/pf.conf` (add anchor include)
- Create: `/Library/LaunchDaemons/com.eagle.pf-redirect.plist`
- [ ] **Step 1: Disable macOS default httpd (if running)**
```bash
sudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plist 2>/dev/null || true
sudo apachectl stop 2>/dev/null || true
```
Expected: no process on port 80 afterwards.
- [ ] **Step 2: Write pf anchor rules**
```bash
sudo tee /etc/pf.anchors/eagle-localhost <<'EOF'
rdr pass on lo0 inet proto tcp from any to 127.0.0.1 port 80 -> 127.0.0.1 port 8880
EOF
```
- [ ] **Step 3: Load the anchor into pf.conf**
Check if `/etc/pf.conf` already has an anchor block. Add if missing:
```bash
grep -q 'eagle-localhost' /etc/pf.conf || sudo tee -a /etc/pf.conf <<'EOF'
# Eagle Dashboard port 80 redirect
rdr-anchor "eagle-localhost"
load anchor "eagle-localhost" from "/etc/pf.anchors/eagle-localhost"
EOF
```
Enable pf and load:
```bash
sudo pfctl -ef /etc/pf.conf
```
- [ ] **Step 4: Create LaunchDaemon to reload pf at boot**
`/Library/LaunchDaemons/com.eagle.pf-redirect.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.eagle.pf-redirect</string>
<key>ProgramArguments</key>
<array>
<string>/sbin/pfctl</string>
<string>-ef</string>
<string>/etc/pf.conf</string>
</array>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/tmp/eagle-pf.log</string>
<key>StandardErrorPath</key><string>/tmp/eagle-pf.log</string>
</dict>
</plist>
```
```bash
sudo launchctl load /Library/LaunchDaemons/com.eagle.pf-redirect.plist
```
- [ ] **Step 5: Verify**
```bash
curl -s http://localhost/api/health # should work once eagle-dash is running
sudo pfctl -s nat # should show the rdr rule
```
Expected: redirect rule visible in pfctl output.
- [ ] **Step 6: Commit plist to repo**
```bash
cd ~/Developer/eagle-dash
cp /Library/LaunchDaemons/com.eagle.pf-redirect.plist .
git add com.eagle.pf-redirect.plist
git commit -m "feat: pf redirect port 80 → 8880 (LaunchDaemon + anchor)"
```
---
## Task 2: Service Registry YAML
**Files:**
@@ -367,14 +458,14 @@ cd ~/Developer/eagle-dash
just dev &
sleep 2
# Should work (localhost):
curl -s http://127.0.0.1:3000/api/health
curl -s http://127.0.0.1:8880/api/health
# Simulate external (should 401):
curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:3000/api/health
curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:8880/api/health
# With token (should work):
TOKEN=$(grep EAGLE_DASH_TOKEN .env | cut -d= -f2)
curl -s -H "X-Forwarded-For: 1.2.3.4" -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/api/health
curl -s -H "X-Forwarded-For: 1.2.3.4" -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8880/api/health
```
Expected: first call `{"status":"ok"}`, second `{"error":"unauthorized"}`, third `{"status":"ok"}`.
@@ -560,12 +651,20 @@ import httpx
import psutil
from registry import Service
@dataclass
class ChildProc:
pid: int
name: str
memory_mb: float
@dataclass
class ServiceStatus:
id: str
healthy: bool
detail: str = ""
latency_ms: Optional[float] = None
memory_mb: Optional[float] = None # RSS of main process, MB
children: list[ChildProc] = field(default_factory=list)
def check_port(port: int, host: str = "127.0.0.1", timeout: float = 1.0) -> bool:
try:
@@ -574,15 +673,36 @@ def check_port(port: int, host: str = "127.0.0.1", timeout: float = 1.0) -> bool
except OSError:
return False
def check_process(name: str) -> bool:
def _rss_mb(proc: psutil.Process) -> float:
try:
return proc.memory_info().rss / 1024 / 1024
except (psutil.NoSuchProcess, psutil.AccessDenied):
return 0.0
def _children_info(proc: psutil.Process) -> list[ChildProc]:
result = []
try:
for child in proc.children(recursive=True):
result.append(ChildProc(
pid=child.pid,
name=child.name(),
memory_mb=round(_rss_mb(child), 1),
))
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return result
def check_process(name: str) -> tuple[bool, Optional[float], list[ChildProc]]:
for proc in psutil.process_iter(["name", "cmdline"]):
try:
cmdline = " ".join(proc.info["cmdline"] or [])
if name in proc.info["name"] or name in cmdline:
return True
mem = round(_rss_mb(proc), 1)
children = _children_info(proc)
return True, mem, children
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return False
return False, None, []
def check_launchctl(label: str) -> bool:
result = subprocess.run(
@@ -601,23 +721,45 @@ def check_http(url: str, timeout: float = 3.0) -> tuple[bool, float]:
except Exception:
return False, 0.0
def _proc_by_label(label: str) -> Optional[psutil.Process]:
"""Find process whose cmdline contains the launchd label."""
for proc in psutil.process_iter(["cmdline"]):
try:
cmdline = " ".join(proc.info["cmdline"] or [])
if label in cmdline:
return proc
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return None
async def check_service(svc: Service) -> ServiceStatus:
h = svc.health
if h.type == "http":
ok, ms = check_http(h.url)
# also try to find process by port for memory info
proc = _proc_by_label(svc.label) if svc.label else None
mem = round(_rss_mb(proc), 1) if proc else None
children = _children_info(proc) if proc else []
return ServiceStatus(id=svc.id, healthy=ok, latency_ms=ms,
memory_mb=mem, children=children,
detail="HTTP ok" if ok else "HTTP failed")
if h.type == "port":
ok = check_port(svc.port)
return ServiceStatus(id=svc.id, healthy=ok,
proc = _proc_by_label(svc.label) if svc.label else None
mem = round(_rss_mb(proc), 1) if proc else None
children = _children_info(proc) if proc else []
return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children,
detail=f"port {svc.port} {'open' if ok else 'closed'}")
if h.type == "process":
ok = check_process(h.name)
return ServiceStatus(id=svc.id, healthy=ok,
ok, mem, children = check_process(h.name)
return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children,
detail=f"process '{h.name}' {'found' if ok else 'not found'}")
if h.type == "launchctl":
ok = check_launchctl(svc.label)
return ServiceStatus(id=svc.id, healthy=ok,
proc = _proc_by_label(svc.label) if ok else None
mem = round(_rss_mb(proc), 1) if proc else None
children = _children_info(proc) if proc else []
return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children,
detail="loaded" if ok else "not loaded")
return ServiceStatus(id=svc.id, healthy=False, detail=f"unknown check type: {h.type}")
```
@@ -743,6 +885,11 @@ async def list_services():
"healthy": status.healthy if status else None,
"detail": status.detail if status else "pending",
"latency_ms": status.latency_ms if status else None,
"memory_mb": status.memory_mb if status else None,
"children": [
{"pid": c.pid, "name": c.name, "memory_mb": c.memory_mb}
for c in (status.children if status else [])
],
})
return result
@@ -787,8 +934,8 @@ async def self_health():
cd ~/Developer/eagle-dash
just dev &
sleep 2
curl -s http://localhost:3000/api/health
curl -s http://localhost:3000/api/services | python3 -m json.tool | head -40
curl -s http://localhost:8880/api/health
curl -s http://localhost:8880/api/services | python3 -m json.tool | head -40
```
Expected: `{"status":"ok","services_registered":14}` and a JSON array of services.
@@ -885,7 +1032,19 @@ The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for intera
</div>
<!-- Detail + latency -->
<p class="text-xs text-slate-400 mb-3" x-text="svc.detail + (svc.latency_ms ? ' · ' + Math.round(svc.latency_ms) + 'ms' : '')"></p>
<p class="text-xs text-slate-400 mb-1" x-text="svc.detail + (svc.latency_ms ? ' · ' + Math.round(svc.latency_ms) + 'ms' : '')"></p>
<!-- Memory usage -->
<template x-if="svc.memory_mb">
<div class="mb-2">
<span class="text-xs text-slate-500" x-text="svc.memory_mb + ' MB'"></span>
<template x-if="svc.children && svc.children.length">
<span class="text-xs text-slate-600 ml-1"
x-text="'+ ' + svc.children.length + ' child' + (svc.children.length > 1 ? 'ren' : '') + ' (' + svc.children.reduce((a,c) => a+c.memory_mb, 0).toFixed(1) + ' MB)'">
</span>
</template>
</div>
</template>
<!-- Network access badge -->
<div class="flex items-center justify-between">
@@ -1061,7 +1220,7 @@ loadPage() {
```bash
cd ~/Developer/eagle-dash
just dev
open http://localhost:3000
open http://localhost:8880
```
Expected: Dark dashboard with tab bar. Services tab shows cards by category. Pages tab shows URL input with iframe. Files tab shows FileBrowser.
@@ -1136,7 +1295,7 @@ And add a "logs" button to each service card (after the restart/stop buttons):
- [ ] **Step 2: Test log panel**
```bash
open http://localhost:3000
open http://localhost:8880
```
Click "logs" on the PostgreSQL card. Expected: bottom drawer opens with last 200 lines of the postgres log.
@@ -1207,7 +1366,7 @@ Expected: `launchctl load` succeeds.
```bash
launchctl list com.eagle.dashboard
curl -s http://localhost:3000/api/health
curl -s http://localhost:8880/api/health
```
Expected: PID shown in launchctl list; `{"status":"ok"}` from curl.
@@ -1222,7 +1381,7 @@ Add to `services.yaml` under `# ── Infrastructure`:
port: 3000
health:
type: http
url: "http://localhost:3000/api/health"
url: "http://localhost:8880/api/health"
access: localhost
critical: false
category: infra
@@ -1233,13 +1392,13 @@ Add to `services.yaml` under `# ── Infrastructure`:
```bash
cd ~/Developer/eagle-dash
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 3000"
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 8880"
```
- [ ] **Step 6: Open and verify**
```bash
open http://localhost:3000
open http://localhost:8880
```
Expected: Dashboard loads, eagle-dashboard itself shows as a green card.
@@ -1353,7 +1512,7 @@ git commit -m "feat: FileBrowser Docker container on :8181"
## Task 10: Cloudflared Tunnel for dash.qentra.top
**Goal:** Expose `dash.qentra.top` via cloudflared → `localhost:3000`. External requests are token-gated by auth middleware (Task 2a).
**Goal:** Expose `dash.qentra.top` via cloudflared → `localhost:8880`. External requests are token-gated by auth middleware (Task 2a).
- [ ] **Step 1: Add ingress rule to cloudflared config**
@@ -1362,11 +1521,11 @@ Check existing config:
cat ~/.cloudflared/config.yml
```
Add a new ingress entry for the dashboard:
Add a new ingress entry for the dashboard (points directly to internal port, bypassing pf):
```yaml
ingress:
- hostname: dash.qentra.top
service: http://localhost:3000
service: http://localhost:8880
# ... existing rules ...
- service: http_status:404
```
@@ -1456,3 +1615,7 @@ Implementation: extend `registry.py` with a `bind_check` health type that calls
- **FileBrowser iframe:** `FB_NOAUTH=true` env var name may differ by image version — check `docker run filebrowser/filebrowser --help` if the login screen still appears. Alternatively set `--username admin --password ""`.
- **Cookie auth and SameSite:** the cookie set by `/login` uses `samesite=strict`, which blocks it on cross-site redirects. Use `samesite=lax` instead if the login flow breaks in some browsers.
- **Pages tab iframe:** `sandbox="allow-same-origin allow-scripts"` will block pages that set `X-Frame-Options: DENY`. This is expected — most external sites won't load; local services usually will.
- **Memory tracking imports:** `ChildProc` uses `field(default_factory=list)` — add `from dataclasses import dataclass, field` and `from typing import Optional` to `health.py`.
- **pf and SIP:** macOS SIP does not block `pfctl` on /etc/pf.conf edits, but some pf operations require rebooting after System Integrity Protection changes. Test with `sudo pfctl -s nat` after loading.
- **Port 80 and launchd ordering:** Eagle Dashboard LaunchAgent must start before the pf LaunchDaemon is useful — both happen at login, but pf redirect is independent (kernel-level), so ordering doesn't matter.
- **Memory for launchctl services:** `_proc_by_label` searches cmdline for the label string — this works for most launchd jobs whose argv[0] contains the plist label. If a service doesn't include its label in cmdline, memory will show `null`.