From 36fe77154b4259a972b36be02ed7e6edc711f11a Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 4 Jun 2026 12:14:19 +0600 Subject: [PATCH] =?UTF-8?q?[2026-06-04]=20eagle-dashboard:=20port=2080=20(?= =?UTF-8?q?pf=20redirect),=20memory=20tracking,=20fix=20port=203000?= =?UTF-8?q?=E2=86=928880=20(Virfield=20conflict)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- personal/projects/eagle-dashboard.md | 213 +++++++++++++++++++++++---- 1 file changed, 188 insertions(+), 25 deletions(-) diff --git a/personal/projects/eagle-dashboard.md b/personal/projects/eagle-dashboard.md index f124fc64..8d49d390 100644 --- a/personal/projects/eagle-dashboard.md +++ b/personal/projects/eagle-dashboard.md @@ -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 + + + + + Labelcom.eagle.pf-redirect + ProgramArguments + + /sbin/pfctl + -ef + /etc/pf.conf + + RunAtLoad + StandardOutPath/tmp/eagle-pf.log + StandardErrorPath/tmp/eagle-pf.log + + +``` + +```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 -

+

+ + +
@@ -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`.