From 2aceaa79a9824be15067ea2634348f17d97ac3cf Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 4 Jun 2026 12:09:33 +0600 Subject: [PATCH] 2026-06-04 eagle-dashboard: add auth, FileBrowser, Pages tab, cloudflared tunnel --- personal/projects/eagle-dashboard.md | 391 ++++++++++++++++++++++++++- 1 file changed, 382 insertions(+), 9 deletions(-) diff --git a/personal/projects/eagle-dashboard.md b/personal/projects/eagle-dashboard.md index 1114b9b7..f124fc64 100644 --- a/personal/projects/eagle-dashboard.md +++ b/personal/projects/eagle-dashboard.md @@ -4,17 +4,17 @@ **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 renders live cards with no build step. `launchctl` integration enables lifecycle control. Runs as its own LaunchAgent on port 3000. +**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`. -**Tech Stack:** Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx +**Tech Stack:** Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx, python-dotenv --- ## Scope note -Two independent subsystems — build them separately: -- **Plan A (this doc):** Service registry + health API + dashboard UI + LaunchAgent control -- **Plan B (future):** Network security panel — cloudflared tunnel management, pf rules, per-service firewall toggling +Three independent subsystems: +- **Plan A (this doc):** Service registry + health API + dashboard UI + LaunchAgent control + auth + FileBrowser + Pages tab + cloudflared tunnel +- **Plan B (future):** Network security panel — pf rules, per-service firewall toggling, bind-address runtime verification --- @@ -23,12 +23,16 @@ Two independent subsystems — build them separately: ``` ~/Developer/eagle-dash/ ├── main.py # FastAPI app — routes, startup, SSE +├── auth.py # Origin-aware auth middleware ├── registry.py # Service registry loader + YAML parser ├── health.py # Health check engine (http/port/process/launchctl) ├── control.py # launchctl start/stop/restart wrappers ├── services.yaml # Service definitions (source of truth) +├── .env # EAGLE_DASH_TOKEN= ├── templates/ -│ └── index.html # Dashboard — HTMX + Tailwind CDN + Alpine.js +│ └── index.html # Dashboard — 3-tab HTMX + Tailwind CDN + Alpine.js +├── docker/ +│ └── filebrowser.yml # FileBrowser Docker Compose ├── requirements.txt ├── justfile └── com.eagle.dashboard.plist # LaunchAgent plist @@ -286,6 +290,105 @@ git add services.yaml && git commit -m "feat: service registry YAML" --- +## Task 2a: Auth Middleware + +**Files:** +- Create: `~/Developer/eagle-dash/auth.py` +- Create: `~/Developer/eagle-dash/.env` + +Origin-aware authentication: requests from `127.0.0.1` (localhost browser) bypass all auth. Requests arriving via cloudflared (any other origin or forwarded header) must present a Bearer token matching `EAGLE_DASH_TOKEN` env var. + +- [ ] **Step 1: Generate a token and write .env** + +```bash +cd ~/Developer/eagle-dash +python3 -c "import secrets; print('EAGLE_DASH_TOKEN=' + secrets.token_urlsafe(32))" > .env +cat .env +``` + +Expected: `EAGLE_DASH_TOKEN=<48-char random string>` + +Add `.env` to `.gitignore`: +```bash +echo ".env" >> .gitignore +``` + +- [ ] **Step 2: Write auth.py** + +`~/Developer/eagle-dash/auth.py`: +```python +import os +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from dotenv import load_dotenv + +load_dotenv() +TOKEN = os.getenv("EAGLE_DASH_TOKEN", "") + +LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost"} + +class OriginAuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + client_host = (request.client.host if request.client else "") + forwarded_for = request.headers.get("X-Forwarded-For", "") + + is_local = ( + client_host in LOCALHOST_HOSTS + and not forwarded_for + ) + if is_local: + return await call_next(request) + + # External request — require Bearer token + auth = request.headers.get("Authorization", "") + if not TOKEN: + return JSONResponse({"error": "token not configured"}, status_code=503) + if auth != f"Bearer {TOKEN}": + return JSONResponse({"error": "unauthorized"}, status_code=401) + return await call_next(request) +``` + +- [ ] **Step 3: Wire into main.py** + +Add to `main.py` imports and app setup (before the first route): +```python +from auth import OriginAuthMiddleware +# after: app = FastAPI(...) +app.add_middleware(OriginAuthMiddleware) +``` + +Also add `python-dotenv` to `requirements.txt`. + +- [ ] **Step 4: Test locally** + +```bash +cd ~/Developer/eagle-dash +just dev & +sleep 2 +# Should work (localhost): +curl -s http://127.0.0.1:3000/api/health + +# Simulate external (should 401): +curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:3000/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 +``` + +Expected: first call `{"status":"ok"}`, second `{"error":"unauthorized"}`, third `{"status":"ok"}`. + +- [ ] **Step 5: Commit** + +```bash +cd ~/Developer/eagle-dash +git add auth.py .gitignore requirements.txt main.py +git commit -m "feat: origin-aware auth middleware — localhost free, external token-gated" +``` + +--- + ## Task 3: Registry Loader **Files:** @@ -704,7 +807,7 @@ git add main.py control.py && git commit -m "feat: FastAPI backend with health A **Files:** - Create: `~/Developer/eagle-dash/templates/index.html` -The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for interactivity. +The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for interactivity. Three tabs: **Services**, **Pages**, **Files**. - [ ] **Step 1: Write index.html** @@ -872,6 +975,87 @@ The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for intera ``` +The `` element uses `x-data="dashboard()"` and tab state `activeTab: 'services'`. Add a tab bar at the top: + +```html + +
+ + + +
+``` + +Wrap the existing category grid in `
`. + +Add a **Pages tab** section: +```html + +
+
+ + + +
+ +
+ +
+ +
+ Enter a URL above to load a page +
+
+``` + +Add a **Files tab** section: +```html + +
+ +
+``` + +Add to the `dashboard()` Alpine object: +```javascript +activeTab: 'services', +pageUrl: '', +pageLoaded: false, +quickLinks: [ + { label: 'Hermes :8000', url: 'http://localhost:8000' }, + { label: 'Postgres :5432', url: 'http://localhost:5432' }, + { label: 'MLX LM :8080', url: 'http://localhost:8080/v1/models' }, + { label: 'Ollama :11434', url: 'http://localhost:11434' }, + { label: 'Zulip :8080', url: 'http://localhost:8080' }, +], +loadPage() { + if (!this.pageUrl) return; + this.pageLoaded = false; + this.$nextTick(() => { this.pageLoaded = true; }); +}, +``` + - [ ] **Step 2: Open the dashboard in browser** ```bash @@ -880,7 +1064,7 @@ just dev open http://localhost:3000 ``` -Expected: Dark dashboard with service cards grouped by category, green/red dots, network badges, restart/stop buttons. +Expected: Dark dashboard with tab bar. Services tab shows cards by category. Pages tab shows URL input with iframe. Files tab shows FileBrowser. - [ ] **Step 3: Verify all categories appear** @@ -890,7 +1074,7 @@ Expected categories: `ai`, `database`, `monitoring`, `orchestration`, `infra` ```bash cd ~/Developer/eagle-dash -git add templates/index.html && git commit -m "feat: dashboard UI with HTMX, Tailwind, Alpine.js" +git add templates/index.html && git commit -m "feat: 3-tab UI — Services, Pages, Files" ``` --- @@ -1062,6 +1246,191 @@ Expected: Dashboard loads, eagle-dashboard itself shows as a green card. --- +## Task 9: FileBrowser Docker Container + +**Files:** +- Create: `~/Developer/eagle-dash/docker/filebrowser.yml` + +FileBrowser is a web UI for browsing and managing files — same image used on TrueNAS. Runs via Colima (Docker on Mac). + +- [ ] **Step 1: Write docker-compose file** + +`~/Developer/eagle-dash/docker/filebrowser.yml`: +```yaml +services: + filebrowser: + image: filebrowser/filebrowser:latest + container_name: eagle-filebrowser + ports: + - "127.0.0.1:8181:80" + volumes: + - /Users/admin:/srv + - filebrowser_db:/database + environment: + - FB_NOAUTH=true # auth handled by Eagle Dashboard token gate + restart: unless-stopped + +volumes: + filebrowser_db: +``` + +Note: `FB_NOAUTH=true` disables FileBrowser's own auth since it's served behind Eagle Dashboard's token middleware (external requests already authenticated before they reach the iframe). + +- [ ] **Step 2: Start it** + +```bash +docker compose -f ~/Developer/eagle-dash/docker/filebrowser.yml up -d +curl -s http://localhost:8181 +``` + +Expected: FileBrowser HTML response. + +- [ ] **Step 3: Add to services.yaml** + +Under `# ── Infrastructure`: +```yaml + filebrowser: + name: "FileBrowser" + label: "eagle-filebrowser" + port: 8181 + health: + type: http + url: "http://localhost:8181" + access: localhost + critical: false + category: infra + log: ~ +``` + +- [ ] **Step 4: Add docker start to justfile** + +```just +filebrowser: + docker compose -f docker/filebrowser.yml up -d + +filebrowser-stop: + docker compose -f docker/filebrowser.yml down +``` + +- [ ] **Step 5: Make it start at login via LaunchAgent** + +Create `~/Library/LaunchAgents/com.eagle.filebrowser.plist`: +```xml + + + + + Labelcom.eagle.filebrowser + ProgramArguments + + /usr/local/bin/docker + compose + -f + /Users/admin/Developer/eagle-dash/docker/filebrowser.yml + up + -d + + RunAtLoad + StandardOutPath/tmp/eagle-filebrowser.log + StandardErrorPath/tmp/eagle-filebrowser.log + + +``` + +```bash +launchctl load ~/Library/LaunchAgents/com.eagle.filebrowser.plist +``` + +- [ ] **Step 6: Commit** + +```bash +cd ~/Developer/eagle-dash +git add docker/ services.yaml justfile +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). + +- [ ] **Step 1: Add ingress rule to cloudflared config** + +Check existing config: +```bash +cat ~/.cloudflared/config.yml +``` + +Add a new ingress entry for the dashboard: +```yaml +ingress: + - hostname: dash.qentra.top + service: http://localhost:3000 + # ... existing rules ... + - service: http_status:404 +``` + +If a `tunnel:` + `credentials-file:` block already exists, just add the ingress line above any catch-all rule. + +- [ ] **Step 2: Restart cloudflared** + +```bash +launchctl kickstart -k gui/$(id -u)/com.cloudflare.cloudflared +``` + +Or if it's a brew service: +```bash +brew services restart cloudflare/cloudflare/cloudflared +``` + +- [ ] **Step 3: Create DNS CNAME in Cloudflare dashboard** + +In Cloudflare DNS for `qentra.top`: +- Type: `CNAME` +- Name: `dash` +- Target: `.cfargotunnel.com` +- Proxy: enabled (orange cloud) + +- [ ] **Step 4: Test external access** + +```bash +TOKEN=$(grep EAGLE_DASH_TOKEN ~/Developer/eagle-dash/.env | cut -d= -f2) +curl -s https://dash.qentra.top/api/health # should 401 +curl -s -H "Authorization: Bearer $TOKEN" https://dash.qentra.top/api/health # should 200 +``` + +- [ ] **Step 5: Add bookmarklet for browser access** + +For convenient external access from a browser, create a bookmarklet that injects the token header. Or simpler — add a `/login` page to FastAPI that sets a cookie: + +`~/Developer/eagle-dash/auth.py` extension: check `eagle_token` cookie in addition to `Authorization` header. The `/login?token=` route sets the cookie and redirects to `/`. This way the browser can store the token in a cookie after one authenticated visit. + +```python +from fastapi import Cookie, Response +from fastapi.responses import RedirectResponse + +# In main.py, add this route: +@app.get("/login") +async def login(token: str, response: Response): + if token != TOKEN: + raise HTTPException(status_code=401, detail="invalid token") + resp = RedirectResponse(url="/") + resp.set_cookie("eagle_token", token, httponly=True, samesite="strict", max_age=86400*30) + return resp +``` + +Update `OriginAuthMiddleware.dispatch` to also check `request.cookies.get("eagle_token")`. + +- [ ] **Step 6: Commit** + +```bash +cd ~/Developer/eagle-dash +git add . && git commit -m "feat: cloudflared tunnel + cookie-based browser auth" +``` + +--- + ## Phase 2 Outline: Network Security Panel *Not in this plan — build after Phase 1 ships.* @@ -1083,3 +1452,7 @@ Implementation: extend `registry.py` with a `bind_check` health type that calls - Some services (Ollama, MLX LM) bind to `0.0.0.0` and are accessible from LAN — displayed as `⚠️ lan` badge. - The heartbeat.sh log path uses `~` — expand it before opening: `Path(svc.log).expanduser()` — already handled in `main.py`. - launchctl `stop` doesn't prevent KeepAlive services from restarting — `unload` is needed for a permanent stop (not exposed in UI on purpose, to prevent accidents). +- **Auth / cloudflared:** cloudflared sets `X-Forwarded-For` but `request.client.host` is still `127.0.0.1` (cloudflared runs locally). Detect external origin by checking `X-Forwarded-For` header presence, not just `client.host`. +- **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.