421 lines
15 KiB
Python
421 lines
15 KiB
Python
"""Hermes Command Center - FastAPI backend with Network Status Dashboard."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
import secrets
|
|
import ssl
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from fastapi import FastAPI, Request, Response, Query
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
# ── Auth ──────────────────────────────────────────────────────────────
|
|
DASHBOARD_USER = os.environ.get("DASHBOARD_USER", "shawn")
|
|
DASHBOARD_PASS = os.environ.get("DASHBOARD_PASS", "Brett85!@")
|
|
|
|
|
|
def _check_auth(auth_header: str | None) -> bool:
|
|
if not auth_header:
|
|
return False
|
|
try:
|
|
decoded = base64.b64decode(auth_header.removeprefix("Basic ")).decode()
|
|
user, _, passwd = decoded.partition(":")
|
|
return secrets.compare_digest(user, DASHBOARD_USER) and secrets.compare_digest(passwd, DASHBOARD_PASS)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
_unauthorized = Response(
|
|
content="<html><body><h1>401 Unauthorized</h1></body></html>",
|
|
status_code=401,
|
|
headers={"WWW-Authenticate": 'Basic realm="Command Center"'},
|
|
)
|
|
|
|
app = FastAPI(title="Hermes Command Center")
|
|
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
OBSIDIAN_VAULT = Path(os.path.expanduser("~/obsidian")).resolve()
|
|
|
|
# ── Network Health Cache ──────────────────────────────────────────────
|
|
INVENTORY_PATH = os.path.expanduser("~/.hermes/network-inventory.yaml")
|
|
|
|
_network_cache: dict = {}
|
|
CACHE_TTL = 30 # seconds
|
|
|
|
|
|
def _load_inventory() -> dict:
|
|
"""Load network inventory from YAML."""
|
|
path = Path(INVENTORY_PATH)
|
|
if not path.exists():
|
|
return {"hosts": [], "services": []}
|
|
with open(path) as f:
|
|
return yaml.safe_load(f) or {}
|
|
|
|
|
|
def _get_host_ip(host: dict) -> str | None:
|
|
"""Return the best IP to check for a given host definition."""
|
|
if host.get("local_ip"):
|
|
return host["local_ip"]
|
|
if host.get("tailscale_ip"):
|
|
return host["tailscale_ip"]
|
|
return None
|
|
|
|
|
|
async def _ping_host(ip: str, timeout: int = 2) -> float | None:
|
|
"""Ping a host and return latency in ms, or None if unreachable."""
|
|
try:
|
|
start = time.monotonic()
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"ping", "-c", "1", "-W", str(timeout), ip,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await proc.wait()
|
|
elapsed = (time.monotonic() - start) * 1000
|
|
if proc.returncode == 0:
|
|
return round(elapsed, 1)
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def _check_port(ip: str, port: int, timeout: int = 2) -> bool:
|
|
"""Check if a TCP port is open on the given host."""
|
|
try:
|
|
_, writer = await asyncio.wait_for(
|
|
asyncio.open_connection(ip, port),
|
|
timeout=timeout,
|
|
)
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return True
|
|
except (OSError, asyncio.TimeoutError):
|
|
return False
|
|
|
|
|
|
async def _check_http(url: str, timeout: int = 3) -> dict:
|
|
"""Check an HTTP(S) endpoint and return status info."""
|
|
import urllib.request
|
|
try:
|
|
req = urllib.request.Request(url, method="HEAD" if url.startswith("http") else "GET")
|
|
start = time.monotonic()
|
|
ctx = ssl._create_unverified_context()
|
|
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
elapsed = (time.monotonic() - start) * 1000
|
|
return {"status": "online", "http_status": resp.status, "latency_ms": round(elapsed, 1)}
|
|
except Exception as e:
|
|
return {"status": "offline", "error": str(e)[:80]}
|
|
|
|
|
|
def _get_local_services() -> list[dict]:
|
|
"""Parse ss -tlnp output for locally listening services."""
|
|
try:
|
|
result = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5)
|
|
services = []
|
|
lines = result.stdout.strip().split("\n")
|
|
for line in lines[1:]:
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) < 4:
|
|
continue
|
|
local = parts[3]
|
|
addr, port_raw = local.rsplit(":", 1)
|
|
proc = parts[-1] if len(parts) > 5 else ""
|
|
addr = addr.strip("[]")
|
|
services.append({
|
|
"local_address": addr,
|
|
"port": port_raw,
|
|
"process": proc,
|
|
})
|
|
return services
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e:
|
|
return [{"error": str(e)}]
|
|
|
|
|
|
async def _check_host(host: dict) -> dict:
|
|
"""Perform health checks on a single host and return enriched status."""
|
|
ip = _get_host_ip(host)
|
|
if not ip:
|
|
safe_host = {k: v for k, v in host.items() if k != "notes"}
|
|
return {**safe_host, "ip": None, "status": "unknown", "reason": "no IP configured", "latency_ms": None, "ports": []}
|
|
|
|
latency = await _ping_host(ip)
|
|
known_ports = []
|
|
if host.get("ssh_port"):
|
|
known_ports.append(("SSH", int(host["ssh_port"])))
|
|
|
|
checked_ports = []
|
|
for name, port in known_ports:
|
|
open_port = await _check_port(ip, port)
|
|
checked_ports.append({"name": name, "port": port, "open": open_port})
|
|
|
|
if latency is not None:
|
|
safe_host = {k: v for k, v in host.items() if k != "notes"}
|
|
return {**safe_host, "ip": ip, "status": "online", "latency_ms": latency, "ports": checked_ports}
|
|
else:
|
|
safe_host = {k: v for k, v in host.items() if k != "notes"}
|
|
return {**safe_host, "ip": ip, "status": "offline", "latency_ms": None, "ports": checked_ports}
|
|
|
|
|
|
async def _check_service(service: dict) -> dict:
|
|
"""Perform health check on a single service definition."""
|
|
result = {k: v for k, v in service.items() if k not in ("notes",)}
|
|
result.setdefault("status", "unknown")
|
|
result["latency_ms"] = None
|
|
result["http_status"] = None
|
|
|
|
# Try HTTP/S URL first
|
|
if service.get("url"):
|
|
check = await _check_http(service["url"])
|
|
result["status"] = check["status"]
|
|
result["latency_ms"] = check.get("latency_ms")
|
|
result["http_status"] = check.get("http_status")
|
|
result["error"] = check.get("error")
|
|
return result
|
|
|
|
# Try port check on 127.0.0.1 (services defined as running locally)
|
|
port = service.get("port")
|
|
if port and service.get("category") != "infrastructure":
|
|
is_open = await _check_port("127.0.0.1", int(port))
|
|
if is_open:
|
|
result["status"] = "online"
|
|
result["latency_ms"] = 0.0
|
|
else:
|
|
result["status"] = "offline"
|
|
|
|
return result
|
|
|
|
|
|
async def _run_health_checks() -> dict:
|
|
"""Run all health checks and return combined results with caching."""
|
|
global _network_cache
|
|
now = time.monotonic()
|
|
|
|
if _network_cache and (now - _network_cache.get("_ts", 0)) < CACHE_TTL:
|
|
return _network_cache
|
|
|
|
inventory = _load_inventory()
|
|
hosts = inventory.get("hosts", [])
|
|
services = inventory.get("services", [])
|
|
|
|
host_tasks = [_check_host(h) for h in hosts]
|
|
host_results = await asyncio.gather(*host_tasks)
|
|
|
|
service_tasks = [_check_service(s) for s in services]
|
|
service_results = await asyncio.gather(*service_tasks)
|
|
|
|
local_services = _get_local_services()
|
|
|
|
result = {
|
|
"hosts": host_results,
|
|
"services": service_results,
|
|
"local_services": local_services,
|
|
"cached_at": time.time(),
|
|
"cache_ttl": CACHE_TTL,
|
|
}
|
|
|
|
_network_cache = result
|
|
_network_cache["_ts"] = now
|
|
return result
|
|
|
|
|
|
# ── Path security ─────────────────────────────────────────────────────
|
|
def _resolve_vault_path(user_path: str) -> Path | None:
|
|
"""Resolve a user-supplied path relative to the vault root.
|
|
|
|
Returns the resolved absolute Path if it's under OBSIDIAN_VAULT,
|
|
or None if the path is invalid/outside the vault.
|
|
"""
|
|
safe = user_path.lstrip("/")
|
|
candidate = (OBSIDIAN_VAULT / safe).resolve()
|
|
try:
|
|
candidate.relative_to(OBSIDIAN_VAULT)
|
|
except ValueError:
|
|
return None
|
|
if not candidate.exists():
|
|
return None
|
|
return candidate
|
|
|
|
|
|
# ── Auth middleware ───────────────────────────────────────────────────
|
|
@app.middleware("http")
|
|
async def auth_middleware(request: Request, call_next):
|
|
if request.url.path in ("/health", "/api/health"):
|
|
return await call_next(request)
|
|
if request.url.path.startswith("/api/network/"):
|
|
return await call_next(request)
|
|
if not _check_auth(request.headers.get("Authorization")):
|
|
return _unauthorized
|
|
return await call_next(request)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "service": "hermes-command-center"}
|
|
|
|
|
|
# ── Network API Endpoints ────────────────────────────────────────────
|
|
|
|
@app.get("/api/network/hosts")
|
|
async def network_hosts():
|
|
"""List all hosts from inventory with live status."""
|
|
data = await _run_health_checks()
|
|
return {"hosts": data["hosts"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]}
|
|
|
|
|
|
@app.get("/api/network/services")
|
|
async def network_services():
|
|
"""List all services with live health check results."""
|
|
data = await _run_health_checks()
|
|
return {"services": data["services"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]}
|
|
|
|
|
|
@app.get("/api/network/services/local")
|
|
async def network_services_local():
|
|
"""List locally running services (from ss -tlnp)."""
|
|
return {"local_services": _get_local_services()}
|
|
|
|
|
|
@app.get("/api/network/refresh")
|
|
async def network_refresh():
|
|
"""Force a fresh health check of all hosts/services."""
|
|
global _network_cache
|
|
_network_cache = {}
|
|
data = await _run_health_checks()
|
|
return {
|
|
"hosts": data["hosts"],
|
|
"services": data["services"],
|
|
"local_services": data["local_services"],
|
|
"cached_at": data["cached_at"],
|
|
"cache_ttl": data["cache_ttl"],
|
|
}
|
|
|
|
|
|
# ── Vault API ─────────────────────────────────────────────────────────
|
|
@app.get("/api/vault/tree")
|
|
async def vault_tree(path: str = Query("", description="Relative path under vault")):
|
|
resolved = _resolve_vault_path(path)
|
|
if resolved is None:
|
|
return JSONResponse({"error": "Path not found or outside vault"}, status_code=404)
|
|
if not resolved.is_dir():
|
|
return JSONResponse({"error": "Not a directory"}, status_code=400)
|
|
|
|
items = []
|
|
try:
|
|
for entry in sorted(resolved.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
|
if entry.name.startswith("."):
|
|
continue
|
|
info = {
|
|
"name": entry.name,
|
|
"type": "folder" if entry.is_dir() else "file",
|
|
"size": entry.stat().st_size if entry.is_file() else 0,
|
|
"modified": int(entry.stat().st_mtime),
|
|
}
|
|
items.append(info)
|
|
except PermissionError:
|
|
return JSONResponse({"error": "Permission denied"}, status_code=403)
|
|
|
|
rel = resolved.relative_to(OBSIDIAN_VAULT)
|
|
return JSONResponse({
|
|
"path": str(rel) if str(rel) != "." else "",
|
|
"items": items,
|
|
})
|
|
|
|
|
|
@app.get("/api/vault/read")
|
|
async def vault_read(path: str = Query(..., description="Relative path to markdown file")):
|
|
resolved = _resolve_vault_path(path)
|
|
if resolved is None:
|
|
return JSONResponse({"error": "File not found or outside vault"}, status_code=404)
|
|
if not resolved.is_file():
|
|
return JSONResponse({"error": "Not a file"}, status_code=400)
|
|
if resolved.suffix.lower() not in (".md", ".txt", ".markdown"):
|
|
return JSONResponse({"error": "Cannot read non-text files"}, status_code=400)
|
|
|
|
try:
|
|
content = resolved.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500)
|
|
|
|
rel = resolved.relative_to(OBSIDIAN_VAULT)
|
|
return JSONResponse({
|
|
"path": str(rel),
|
|
"name": resolved.name,
|
|
"content": content,
|
|
"size": resolved.stat().st_size,
|
|
"modified": int(resolved.stat().st_mtime),
|
|
})
|
|
|
|
|
|
@app.get("/api/vault/search")
|
|
async def vault_search(q: str = Query(..., min_length=1, description="Search query")):
|
|
"""Full-text grep-style search across all .md files under the vault root."""
|
|
results = []
|
|
try:
|
|
cmd = [
|
|
"grep", "-rn",
|
|
"--include=*.md",
|
|
"-i",
|
|
"-E", q,
|
|
str(OBSIDIAN_VAULT),
|
|
]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
if proc.returncode not in (0, 1):
|
|
return JSONResponse({"error": f"Search failed: {proc.stderr.strip()}"}, status_code=500)
|
|
|
|
for line in proc.stdout.splitlines():
|
|
parts = line.split(":", 2)
|
|
if len(parts) < 3:
|
|
continue
|
|
filepath, lineno, content = parts
|
|
fp = Path(filepath).resolve()
|
|
try:
|
|
fp.relative_to(OBSIDIAN_VAULT)
|
|
except ValueError:
|
|
continue
|
|
|
|
rel_path = str(fp.relative_to(OBSIDIAN_VAULT))
|
|
idx = content.lower().find(q.lower())
|
|
start = max(0, idx - 60)
|
|
end = min(len(content), idx + len(q) + 60)
|
|
snippet = content[start:end].strip()
|
|
if start > 0:
|
|
snippet = "..." + snippet
|
|
if end < len(content):
|
|
snippet = snippet + "..."
|
|
|
|
results.append({
|
|
"path": rel_path,
|
|
"line": int(lineno),
|
|
"snippet": snippet,
|
|
})
|
|
except subprocess.TimeoutExpired:
|
|
return JSONResponse({"error": "Search timed out"}, status_code=504)
|
|
except FileNotFoundError:
|
|
return JSONResponse({"error": "grep not available on this system"}, status_code=500)
|
|
|
|
return JSONResponse({
|
|
"query": q,
|
|
"count": len(results),
|
|
"results": results,
|
|
})
|
|
|
|
|
|
# ── Serve frontend ────────────────────────────────────────────────────
|
|
if STATIC_DIR.exists():
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index():
|
|
index_path = STATIC_DIR / "index.html"
|
|
if index_path.exists():
|
|
return HTMLResponse(content=index_path.read_text())
|
|
return HTMLResponse(content="<h1>Command Center - Loading...</h1>")
|