2026-06-04 eagle-dashboard: add auth, FileBrowser, Pages tab, cloudflared tunnel

This commit is contained in:
Alexey Martemyanov
2026-06-04 12:09:33 +06:00
parent a748477dce
commit 2aceaa79a9
+382 -9
View File
@@ -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=<secret>
├── 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
</html>
```
The `<body>` element uses `x-data="dashboard()"` and tab state `activeTab: 'services'`. Add a tab bar at the top:
```html
<!-- Tab Bar -->
<div class="flex gap-1 mb-6 border-b border-slate-700 pb-2">
<button @click="activeTab='services'"
:class="activeTab==='services' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
class="px-4 py-1.5 text-sm font-medium -mb-2">Services</button>
<button @click="activeTab='pages'"
:class="activeTab==='pages' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
class="px-4 py-1.5 text-sm font-medium -mb-2">Pages</button>
<button @click="activeTab='files'"
:class="activeTab==='files' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
class="px-4 py-1.5 text-sm font-medium -mb-2">Files</button>
</div>
```
Wrap the existing category grid in `<div x-show="activeTab==='services'">`.
Add a **Pages tab** section:
```html
<!-- Pages Tab -->
<div x-show="activeTab==='pages'" class="flex flex-col gap-4 h-[80vh]">
<div class="flex gap-2">
<input x-model="pageUrl" @keyup.enter="loadPage()"
placeholder="http://localhost:8080/..."
class="flex-1 bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-sm text-white
font-mono placeholder-slate-500 focus:outline-none focus:border-indigo-500" />
<button @click="loadPage()"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 rounded text-sm text-white">Load</button>
<button @click="pageUrl=''; pageLoaded=false"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded text-sm text-slate-300">Clear</button>
</div>
<!-- Quick links to common local services -->
<div class="flex gap-2 flex-wrap">
<template x-for="link in quickLinks" :key="link.url">
<button @click="pageUrl=link.url; loadPage()"
class="text-xs px-2 py-1 bg-slate-800 hover:bg-slate-700 border border-slate-600
rounded text-slate-400 hover:text-white">
<span x-text="link.label"></span>
</button>
</template>
</div>
<iframe x-show="pageLoaded" :src="pageUrl" class="flex-1 rounded border border-slate-700 bg-white"
sandbox="allow-same-origin allow-scripts allow-forms"></iframe>
<div x-show="!pageLoaded" class="flex-1 rounded border border-slate-700 flex items-center
justify-center text-slate-600 text-sm">
Enter a URL above to load a page
</div>
</div>
```
Add a **Files tab** section:
```html
<!-- Files Tab -->
<div x-show="activeTab==='files'" class="h-[80vh]">
<iframe src="http://localhost:8181"
class="w-full h-full rounded border border-slate-700"
allow="fullscreen"></iframe>
</div>
```
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
<?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.filebrowser</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/docker</string>
<string>compose</string>
<string>-f</string>
<string>/Users/admin/Developer/eagle-dash/docker/filebrowser.yml</string>
<string>up</string>
<string>-d</string>
</array>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/tmp/eagle-filebrowser.log</string>
<key>StandardErrorPath</key><string>/tmp/eagle-filebrowser.log</string>
</dict>
</plist>
```
```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: `<your-tunnel-id>.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=<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.