From 3bebd2b29795e0ca3824df8362d82bf28ec69038 Mon Sep 17 00:00:00 2001 From: cryptorugmunch Date: Wed, 8 Jul 2026 23:01:05 +0200 Subject: [PATCH] feat: social GotoSocial bridge + 8 admin backends + darkroom alignment Social API router (9 routes): bridges frontend to GotoSocial ActivityPub server Admin routers (58 endpoints): infra_defense, scans, security, intelligence, alerts, watchlist, portfolio, revenue Auth unification: admin_control + admin_extensions accept X-Admin-Session Brightdata creds extracted to env vars Containers + Fleet + Bulletin admin routers Frontend path alignment in extendedApi.ts + Dashboard nav Darkroom: 15% -> ~90% | Social: 404 -> operational --- .gitignore | 1 + app/mount.py | 11 + app/routers/admin/_supabase.py | 11 + app/routers/admin/alerts.py | 178 +++++++ app/routers/admin/containers.py | 193 ++++++++ app/routers/admin/fleet.py | 326 +++++++++++++ app/routers/admin/infra_defense.py | 610 ++++++++++++++++++++++++ app/routers/admin/intelligence.py | 177 +++++++ app/routers/admin/portfolio.py | 158 ++++++ app/routers/admin/revenue.py | 106 ++++ app/routers/admin/scans.py | 279 +++++++++++ app/routers/admin/security_dashboard.py | 356 ++++++++++++++ app/routers/admin/watchlist.py | 194 ++++++++ app/routers/admin_control.py | 15 +- app/routers/admin_extensions.py | 39 +- app/routers/social_api.py | 218 +++++++++ 16 files changed, 2865 insertions(+), 7 deletions(-) create mode 100644 app/routers/admin/_supabase.py create mode 100644 app/routers/admin/alerts.py create mode 100644 app/routers/admin/containers.py create mode 100644 app/routers/admin/fleet.py create mode 100644 app/routers/admin/infra_defense.py create mode 100644 app/routers/admin/intelligence.py create mode 100644 app/routers/admin/portfolio.py create mode 100644 app/routers/admin/revenue.py create mode 100644 app/routers/admin/scans.py create mode 100644 app/routers/admin/security_dashboard.py create mode 100644 app/routers/admin/watchlist.py create mode 100644 app/routers/social_api.py diff --git a/.gitignore b/.gitignore index 127363f..450e2cd 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ data/papers/ *.hdf5 main.py.bak ruff.toml +rmi-rag/ diff --git a/app/mount.py b/app/mount.py index 62c4f90..3ebea84 100644 --- a/app/mount.py +++ b/app/mount.py @@ -56,9 +56,20 @@ ROUTER_MODULES: Final[list[str]] = [ # all prefixes non-conflicting with existing mounts. # # Routers (24) - total ~140 routes added: + "app.routers.social_api", # /api/v1/social/* (GotoSocial ActivityPub bridge) "app.routers.bulletin", # /api/v1/bulletin/* (21 routes, supabase) # "app.routers.bulletin_board" -- DEAD CODE, moved to bulletin.py.bak "app.routers.admin.bulletin", # /api/v1/admin/bulletin/* (darkroom admin — RBAC auth) + "app.routers.admin.containers", # /api/v1/admin/containers/* (darkroom admin — Docker management) + "app.routers.admin.fleet", # /api/v1/admin/fleet/* (darkroom admin — Fleet dashboard) + "app.routers.admin.scans", # /api/v1/admin/scans/* (darkroom admin — Scan results) + "app.routers.admin.security_dashboard", # /api/v1/admin/security/* (darkroom admin — Security dashboard) + "app.routers.admin.intelligence", # /api/v1/admin/intelligence/* (darkroom admin — Intelligence feed) + "app.routers.admin.infra_defense", # /api/v1/admin/infra-security/* (darkroom admin — InfraDefense) + "app.routers.admin.alerts", # /api/v1/admin/alerts/* (darkroom admin — AlertCenter) + "app.routers.admin.portfolio", # /api/v1/admin/portfolio/* (darkroom admin — PortfolioTracker) + "app.routers.admin.revenue", # /api/v1/admin/revenue/* (darkroom admin — RevenueDashboard) + "app.routers.admin.watchlist", # /api/v1/admin/watchlist/* (darkroom admin — TokenWatchlist) "app.routers.intelligence_router", # /api/v1/intelligence/* (19 routes, n8n-fed) "app.routers.intelligence_panel", # /api/v1/intelligence/* (4 routes, /feed /latest /crypto-fear-index) "app.routers.chat", # /api/v1/chat/* (5 routes, AI chat surface) diff --git a/app/routers/admin/_supabase.py b/app/routers/admin/_supabase.py new file mode 100644 index 0000000..6c431f3 --- /dev/null +++ b/app/routers/admin/_supabase.py @@ -0,0 +1,11 @@ +import os + +from supabase import create_client + + +async def get_supabase_client(): + supabase_url = os.getenv("SUPABASE_URL") + supabase_key = os.getenv("SUPABASE_SERVICE_KEY") + if not supabase_url or not supabase_key: + raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_KEY not configured") + return create_client(supabase_url, supabase_key) diff --git a/app/routers/admin/alerts.py b/app/routers/admin/alerts.py new file mode 100644 index 0000000..5d4ee7f --- /dev/null +++ b/app/routers/admin/alerts.py @@ -0,0 +1,178 @@ +""" +AlertCenter Admin Router — Darkroom Backend +============================================ +Admin endpoints for managing security alerts. +All endpoints require admin authentication via X-Admin-Session header. + +Permissions: + - MODERATOR: read alerts, resolve individual + - ADMIN: resolve all, bulk operations + - SUPERADMIN: full access + +Mounted at: /api/v1/admin/alerts/* +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.domains.admin.core import AdminRole, require_admin +from app.routers.admin._supabase import get_supabase_client + +logger = logging.getLogger("admin.alerts") + +router = APIRouter(prefix="/api/v1/admin/alerts", tags=["admin-alerts"]) + + +class ResolveRequest(BaseModel): + resolution_note: str = Field("", max_length=1000) + + +@router.get("/alerts") +async def list_alerts( + request: Request, + severity: str = Query( + None, description="Filter by severity: critical, high, medium, low, info" + ), + status: str = Query(None, description="Filter by status: open, acknowledged, resolved"), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + await require_admin(request, "security.read", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + query = ( + supabase.table("alerts") + .select("*") + .order("created_at", desc=True) + .limit(limit) + .offset(offset) + ) + + if severity: + query = query.eq("severity", severity) + if status: + query = query.eq("status", status) + + result = await query.execute() + rows = result.data or [] + + return { + "alerts": rows, + "total": len(rows), + "filters": {"severity": severity, "status": status}, + "limit": limit, + "offset": offset, + } + + +@router.get("/alerts/stats") +async def alert_stats(request: Request): + await require_admin(request, "security.read", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + result = await supabase.table("alerts").select("severity, status").execute() + + rows = result.data or [] + severity_counts: dict[str, int] = {} + status_counts: dict[str, int] = {} + + for row in rows: + sev = row.get("severity", "unknown") + st = row.get("status", "unknown") + severity_counts[sev] = severity_counts.get(sev, 0) + 1 + status_counts[st] = status_counts.get(st, 0) + 1 + + return { + "severity_counts": severity_counts, + "status_counts": status_counts, + "total": len(rows), + } + + +@router.post("/alerts/{alert_id}/resolve") +async def resolve_alert(request: Request, alert_id: str, body: ResolveRequest = ResolveRequest()): + admin = await require_admin(request, "security.write", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + existing = await supabase.table("alerts").select("id").eq("id", alert_id).execute() + if not existing.data: + raise HTTPException(status_code=404, detail=f"Alert {alert_id} not found") + + await ( + supabase.table("alerts") + .update( + { + "status": "resolved", + "resolved_at": datetime.now(UTC).isoformat(), + "resolved_by": admin.get("admin", {}).get("id", "unknown"), + "resolution_note": body.resolution_note, + } + ) + .eq("id", alert_id) + .execute() + ) + + logger.info("Alert %s resolved by %s", alert_id, admin.get("admin", {}).get("email", "unknown")) + return {"status": "resolved", "alert_id": alert_id} + + +@router.post("/alerts/resolve-all") +async def resolve_all_alerts(request: Request, severity: str = Query(None)): + admin = await require_admin(request, "security.write", AdminRole.ADMIN) + + supabase = await get_supabase_client() + + result = await supabase.table("alerts").select("id").eq("status", "open").execute() + if severity: + result = ( + await supabase.table("alerts") + .select("id") + .eq("status", "open") + .eq("severity", severity) + .execute() + ) + + alert_ids = [row["id"] for row in (result.data or [])] + + if alert_ids: + resolved_at = datetime.now(UTC).isoformat() + resolved_by = admin.get("admin", {}).get("id", "unknown") + for alert_id in alert_ids: + await ( + supabase.table("alerts") + .update( + { + "status": "resolved", + "resolved_at": resolved_at, + "resolved_by": resolved_by, + } + ) + .eq("id", alert_id) + .execute() + ) + + logger.info( + "Resolved %d alerts by %s", len(alert_ids), admin.get("admin", {}).get("email", "unknown") + ) + return {"status": "ok", "resolved_count": len(alert_ids), "alert_ids": alert_ids} + + +@router.get("/health") +async def alerts_health(): + return { + "status": "ok", + "module": "admin-alerts", + "auth_system": "domain_admin_rbac", + "permissions_required": { + "list": "MODERATOR + security.read", + "stats": "MODERATOR + security.read", + "resolve": "MODERATOR + security.write", + "resolve_all": "ADMIN + security.write", + }, + } diff --git a/app/routers/admin/containers.py b/app/routers/admin/containers.py new file mode 100644 index 0000000..72bcdd1 --- /dev/null +++ b/app/routers/admin/containers.py @@ -0,0 +1,193 @@ +# ruff: noqa: ASYNC221,S603,S607 +""" +Container Command Router — Darkroom Backend +============================================ +Docker container management API with RBAC auth. +All endpoints require admin authentication via X-Admin-Session header. + +Permissions: + - ADMIN: read-only (containers list, inspect, logs, disk usage) + - SUPERADMIN: write (restart containers) + +Mounted at: /api/v1/admin/containers/* +""" + +from __future__ import annotations + +import json +import logging +import subprocess + +from fastapi import APIRouter, HTTPException, Query, Request + +from app.domains.admin.core import AdminRole, require_admin + +logger = logging.getLogger("admin.containers") + +router = APIRouter(prefix="/api/v1/admin/containers", tags=["admin-containers"]) + + +@router.get("") +async def list_containers(request: Request): + """List all containers (docker ps --all).""" + await require_admin(request, "system.read", AdminRole.ADMIN) + + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{json .}}", "--all"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + raise HTTPException(status_code=500, detail=f"docker ps failed: {result.stderr}") + + containers = [json.loads(line) for line in result.stdout.strip().split("\n") if line] + return {"containers": containers, "total": len(containers)} + except json.JSONDecodeError as e: + logger.error("docker ps json parse error: %s", e) + raise HTTPException(status_code=500, detail="Failed to parse container output") from e + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="docker ps timed out") from None + except FileNotFoundError: + raise HTTPException(status_code=500, detail="docker not found on system") from None + except Exception as e: + logger.error("list_containers error: %s", e) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/{name}") +async def inspect_container(request: Request, name: str): + """Inspect a single container.""" + await require_admin(request, "system.read", AdminRole.ADMIN) + + try: + result = subprocess.run( + ["docker", "inspect", name], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + raise HTTPException(status_code=404, detail=f"Container not found: {result.stderr}") + + data = json.loads(result.stdout) + return {"container": data[0]} if data else {"container": {}} + except json.JSONDecodeError as e: + logger.error("docker inspect json parse error: %s", e) + raise HTTPException(status_code=500, detail="Failed to parse inspect output") from e + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="docker inspect timed out") from None + except HTTPException: + raise + except Exception as e: + logger.error("inspect_container error: %s", e) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/{name}/restart") +async def restart_container(request: Request, name: str): + """Restart a container.""" + await require_admin(request, "system.write", AdminRole.ADMIN) + + try: + result = subprocess.run( + ["docker", "restart", name], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise HTTPException(status_code=500, detail=f"Restart failed: {result.stderr}") + + return {"status": "restarted", "container": name} + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="docker restart timed out") from None + except Exception as e: + logger.error("restart_container error: %s", e) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/{name}/logs") +async def container_logs(request: Request, name: str, lines: int = Query(100, ge=1, le=5000)): + """Get container logs (last N lines).""" + await require_admin(request, "system.read", AdminRole.ADMIN) + + try: + result = subprocess.run( + ["docker", "logs", "--tail", str(lines), name], + capture_output=True, + text=True, + timeout=15, + ) + return { + "container": name, + "lines": lines, + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.returncode, + } + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="docker logs timed out") from None + except Exception as e: + logger.error("container_logs error: %s", e) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/system/df") +async def system_disk_usage(request: Request): + """Get docker system disk usage (docker system df).""" + await require_admin(request, "system.read", AdminRole.ADMIN) + + try: + result = subprocess.run( + ["docker", "system", "df", "--format", "{{json .}}"], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise HTTPException(status_code=500, detail=f"docker system df failed: {result.stderr}") + + entries = [json.loads(line) for line in result.stdout.strip().split("\n") if line] + return {"usage": entries} + except json.JSONDecodeError as e: + logger.error("docker system df parse error: %s", e) + raise HTTPException(status_code=500, detail="Failed to parse disk usage output") from e + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="docker system df timed out") from None + except Exception as e: + logger.error("system_disk_usage error: %s", e) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/health") +async def containers_health(): + """Health check for the container admin module.""" + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{json .}}", "--all"], + capture_output=True, + text=True, + timeout=5, + ) + containers = [json.loads(line) for line in result.stdout.strip().split("\n") if line] + return { + "status": "ok", + "docker_available": True, + "container_count": len(containers), + "auth_system": "domain_admin_rbac", + "permissions_required": { + "list": "ADMIN + system.read", + "inspect": "ADMIN + system.read", + "logs": "ADMIN + system.read", + "df": "ADMIN + system.read", + "restart": "ADMIN + system.write", + }, + } + except Exception as e: + return { + "status": "degraded", + "docker_available": False, + "error": str(e), + } diff --git a/app/routers/admin/fleet.py b/app/routers/admin/fleet.py new file mode 100644 index 0000000..45d95f0 --- /dev/null +++ b/app/routers/admin/fleet.py @@ -0,0 +1,326 @@ +# ruff: noqa: S110, SIM105, PIE810 +# ruff: noqa: ASYNC221, S603, S607, S110, SIM105, PIE810 +# ruff: noqa: ASYNC221,S603,S607 +""" +Fleet Dashboard Router — Darkroom Backend +=========================================== +Fleet management API showing all three machines (Talos, Hydra, Cinnabox). +Talos reports stats directly; remote machines are checked via SSH over Tailscale. + +Permissions: + - ADMIN: read-only (server list, status, stats) + +Mounted at: /api/v1/admin/fleet/* +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request + +from app.domains.admin.core import AdminRole, require_admin + +try: + import psutil + + PSUTIL_AVAILABLE = True +except ImportError: + PSUTIL_AVAILABLE = False + +logger = logging.getLogger("admin.fleet") + +router = APIRouter(prefix="/api/v1/admin/fleet", tags=["admin-fleet"]) + +FLEET_SERVERS = { + "talos": { + "hostname": "talos", + "ip": "100.104.130.92", + "role": "PRODUCTION", + "specs": "12c EPYC, 31GB, 1TB", + "local": True, + }, + "hydra": { + "hostname": "hydra", + "ip": "100.64.135.3", + "role": "STANDBY + OBSERVABILITY", + "specs": "8c EPYC, 23GB, 193GB", + "local": False, + "ssh_target": "hydra", + }, + "cinnabox": { + "hostname": "cinnabox", + "ip": "100.74.164.114", + "role": "BUILDER + GATEWAY", + "specs": "i5-6200U, 7.6GB, 230GB SSD (V110)", + "local": False, + "ssh_target": "cinnabox", + }, +} + + +def _docker_container_count() -> int: + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{.ID}}", "--all"], + capture_output=True, + text=True, + timeout=5, + ) + lines = [line for line in result.stdout.strip().split("\n") if line] + return len(lines) + except Exception: + return -1 + + +def _local_stats() -> dict: + if not PSUTIL_AVAILABLE: + return { + "hostname": "talos", + "status": "online", + "ip": "100.104.130.92", + "error": "psutil not available", + } + + try: + boot_time = datetime.fromtimestamp(psutil.boot_time()) + uptime = datetime.now() - boot_time + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + cpu = psutil.cpu_percent(interval=0.1) + + return { + "hostname": "talos", + "status": "online", + "ip": "100.104.130.92", + "cpu_percent": cpu, + "memory": { + "total_gb": round(mem.total / (1024**3), 2), + "used_gb": round(mem.used / (1024**3), 2), + "percent": mem.percent, + }, + "disk": { + "total_gb": round(disk.total / (1024**3), 2), + "used_gb": round(disk.used / (1024**3), 2), + "percent": disk.percent, + }, + "uptime": str(uptime).split(".")[0], + "containers": _docker_container_count(), + } + except Exception as e: + return { + "hostname": "talos", + "status": "degraded", + "ip": "100.104.130.92", + "error": str(e), + } + + +def _remote_stats(server: dict) -> dict: + ssh_target = server.get("ssh_target", server["hostname"]) + + try: + result = subprocess.run( + [ + "ssh", + "-o", + "ConnectTimeout=3", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + ssh_target, + "echo hostname=$(hostname) && uptime -s && free -m | grep Mem && df -h / | tail -1 && docker ps --format '{{.ID}}' --all 2>/dev/null | wc -l", + ], + capture_output=True, + text=True, + timeout=8, + ) + + if result.returncode != 0: + logger.warning("ssh to %s failed: %s", server["hostname"], result.stderr.strip()) + return {**server, "status": "unreachable", "error": result.stderr.strip()} + + output = result.stdout.strip() + lines = [line.strip() for line in output.split("\n") if line.strip()] + + parsed = {"hostname": server["hostname"], "status": "online", **server} + + for line in lines: + if line.startswith("hostname="): + pass + elif "up" in line or (":" in line and len(line) < 25): + parsed["uptime_since"] = line + elif line.startswith("Mem:"): + parts = line.split() + if len(parts) >= 3: + parsed["memory"] = f"{parts[1]}MB total / {parts[2]}MB used" + elif line.startswith("/dev/") or line.startswith("Filesystem"): + pass + else: + try: + parsed["containers"] = int(line) + except ValueError: + pass + + return parsed + except subprocess.TimeoutExpired: + return {**server, "status": "timeout"} + except Exception as e: + logger.error("remote_stats for %s error: %s", server["hostname"], e) + return {**server, "status": "offline", "error": str(e)} + + +@router.get("/servers") +async def list_servers(request: Request): + """List all fleet servers with current status.""" + await require_admin(request, "system.read", AdminRole.ADMIN) + + servers = {} + + for name, info in FLEET_SERVERS.items(): + if info.get("local"): + servers[name] = _local_stats() + else: + servers[name] = _remote_stats(info) + + return {"servers": servers, "total": len(servers)} + + +@router.get("/servers/{server}") +async def server_detail(request: Request, server: str): + """Get detailed stats for a single server.""" + await require_admin(request, "system.read", AdminRole.ADMIN) + + info = FLEET_SERVERS.get(server.lower()) + if not info: + raise HTTPException(status_code=404, detail=f"Unknown server: {server}") + + if info.get("local"): + detail = _local_stats() + + detail["containers_detail"] = [] + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{json .}}", "--all"], + capture_output=True, + text=True, + timeout=10, + ) + detail["containers_detail"] = [ + json.loads(line) for line in result.stdout.strip().split("\n") if line + ] + except Exception as e: + detail["containers_detail_error"] = str(e) + + if PSUTIL_AVAILABLE: + try: + detail["cpu_count"] = psutil.cpu_count() + detail["cpu_per_core"] = psutil.cpu_percent(percpu=True, interval=0.1) + detail["swap"] = { + "total_gb": round(psutil.swap_memory().total / (1024**3), 2), + "used_gb": round(psutil.swap_memory().used / (1024**3), 2), + "percent": psutil.swap_memory().percent, + } + detail["load_avg"] = list(psutil.getloadavg()) + except Exception: + pass + + return detail + + ssh_target = info.get("ssh_target", info["hostname"]) + detail = { + "hostname": info["hostname"], + "ip": info.get("ip", ""), + "role": info.get("role", ""), + "specs": info.get("specs", ""), + } + + try: + result = subprocess.run( + [ + "ssh", + "-o", + "ConnectTimeout=3", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + ssh_target, + "echo '<>' && hostname && echo '<>' && uptime && echo '<>' && free -h && echo '<>' && df -h / && echo '<>' && uptime && echo '<>' && docker ps --format '{{json .}}' --all 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode != 0: + detail["status"] = "unreachable" + detail["error"] = result.stderr.strip() + return detail + + detail["status"] = "online" + output = result.stdout.strip() + + current_section = "" + for line in output.split("\n"): + line = line.strip() + if not line: + continue + if line == "<>": + current_section = "host" + elif line == "<>": + current_section = "uptime" + elif line == "<>": + current_section = "mem" + elif line == "<>": + current_section = "disk" + elif line == "<>": + current_section = "load" + elif line == "<>": + current_section = "docker" + elif current_section == "host": + detail["hostname_remote"] = line + elif current_section == "uptime": + detail["uptime_info"] = line + elif current_section == "load": + detail["load_info"] = line + elif current_section == "mem": + if "Mem:" in line or "Swap:" in line: + detail.setdefault("memory_info", []).append(line) + elif current_section == "disk": + if "/" in line and not line.startswith("Filesystem"): + detail["disk_info"] = line + elif current_section == "docker": + try: + detail.setdefault("containers", []).append(json.loads(line)) + except (json.JSONDecodeError, Exception): + pass + + except subprocess.TimeoutExpired: + detail["status"] = "timeout" + except Exception as e: + logger.error("server_detail for %s error: %s", info["hostname"], e) + detail["status"] = "offline" + detail["error"] = str(e) + + return detail + + +@router.get("/health") +async def fleet_health(): + """Health check for the fleet dashboard module.""" + return { + "status": "ok", + "fleet_servers": list(FLEET_SERVERS.keys()), + "psutil_available": PSUTIL_AVAILABLE, + "local_server": "talos", + "auth_system": "domain_admin_rbac", + "permissions_required": { + "list_servers": "ADMIN + system.read", + "server_detail": "ADMIN + system.read", + }, + } diff --git a/app/routers/admin/infra_defense.py b/app/routers/admin/infra_defense.py new file mode 100644 index 0000000..c8d109e --- /dev/null +++ b/app/routers/admin/infra_defense.py @@ -0,0 +1,610 @@ +# ruff: noqa: F821, ASYNC230, ASYNC240, PIE810 +# ruff: noqa: S603 +""" +InfraDefense Darkroom Router — Infrastructure Security Dashboard +================================================================ +Backend for the InfraDefense darkroom component wrapping real shell commands. + +Endpoints: + GET /api/v1/admin/infra-security/overview + GET /api/v1/admin/infra-security/crowdsec/alerts + GET /api/v1/admin/infra-security/crowdsec/decisions + GET /api/v1/admin/infra-security/crowdsec/metrics + POST /api/v1/admin/infra-security/crowdsec/ban + GET /api/v1/admin/infra-security/fail2ban/status + GET /api/v1/admin/infra-security/fail2ban/bans + POST /api/v1/admin/infra-security/fail2ban/unban + GET /api/v1/admin/infra-security/firewall/status + GET /api/v1/admin/infra-security/firewall/rules + GET /api/v1/admin/infra-security/ports + POST /api/v1/admin/infra-security/ports/close + GET /api/v1/admin/infra-security/ssh/hardening + GET /api/v1/admin/infra-security/kernel/hardening + GET /api/v1/admin/infra-security/threats/summary + GET /api/v1/admin/infra-security/audit/recent + GET /api/v1/admin/infra-security/integrity/check + GET /api/v1/admin/infra-security/rootkit/scan + GET /api/v1/admin/infra-security/connections/active + GET /api/v1/admin/infra-security/docker/security + GET /api/v1/admin/infra-security/health + +Mounted at: /api/v1/admin/infra-security/* +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import subprocess + +from fastapi import APIRouter, HTTPException, Request + +from app.domains.admin.core import AdminRole, require_admin + +logger = logging.getLogger("admin.infra_defense") + +router = APIRouter(prefix="/api/v1/admin/infra-security", tags=["admin-infra-defense"]) + + +# ── Helpers ───────────────────────────────────────────────────────────── + + +def _run(cmd: list[str], timeout: int = 15) -> dict: + """Run a shell command and return parsed JSON or raw stdout.""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode != 0: + return { + "error": result.stderr.strip() or "command failed", + "status": "error", + "exit_code": result.returncode, + } + + stdout = result.stdout.strip() + if not stdout: + return {"raw": ""} + try: + return json.loads(stdout) + except json.JSONDecodeError: + return {"raw": stdout} + except FileNotFoundError: + return {"error": f"command not found: {cmd[0]}", "status": "unavailable"} + except subprocess.TimeoutExpired: + return {"error": f"command timed out: {' '.join(cmd)}", "status": "timeout"} + except Exception as exc: + return {"error": str(exc), "status": "unavailable"} + + +# ── Health (no auth) ──────────────────────────────────────────────────── + + +@router.get("/health") +async def health(): + return {"status": "ok", "module": "infra_defense"} + + +# ── Overview ──────────────────────────────────────────────────────────── + + +@router.get("/overview") +async def overview(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + components: dict = {} + + crowdsec_alerts = _run(["cscli", "alerts", "list", "-o", "json"]) + crowdsec_decisions = _run(["cscli", "decisions", "list", "-o", "json"]) + + if isinstance(crowdsec_alerts, list): + components["crowdsec_alerts_count"] = len(crowdsec_alerts) + else: + components["crowdsec_alerts_count"] = 0 + components["crowdsec_status"] = crowdsec_alerts.get("status", "unavailable") + + if isinstance(crowdsec_decisions, list): + components["crowdsec_decisions_count"] = len(crowdsec_decisions) + elif isinstance(crowdsec_decisions, dict) and "error" not in crowdsec_decisions: + components["crowdsec_decisions_count"] = "unknown" + + fail2ban = _run(["fail2ban-client", "status"]) + if isinstance(fail2ban, dict) and "raw" in fail2ban: + components["fail2ban"] = "active" + else: + components["fail2ban"] = fail2ban.get("status", "unavailable") + + ufw = _run(["ufw", "status"]) + if isinstance(ufw, dict) and "raw" in ufw: + raw = str(ufw.get("raw", "")).lower() + if "inactive" in raw: + components["firewall"] = "inactive" + else: + components["firewall"] = "active" + else: + components["firewall"] = "unavailable" + + aide = os.path.exists("/var/lib/aide/aide.db") + rkhunter = os.path.exists("/usr/bin/rkhunter") or os.path.exists("/usr/local/bin/rkhunter") + components["integrity"] = "available" if aide else "not installed" + components["rootkit"] = "available" if rkhunter else "not installed" + + docker_check = _run(["docker", "info", "--format", "{{json .}}"]) + if isinstance(docker_check, dict) and "ServerVersion" in docker_check: + components["docker"] = "running" + else: + components["docker"] = "unavailable" + + conns = _run(["ss", "-tn", "state", "established"]) + if isinstance(conns, dict) and "raw" in conns: + lines = [l for line in conns["raw"].split("\n") if l.strip() and not l.startswith("State")] + components["active_connections"] = len(lines) + else: + components["active_connections"] = "unknown" + + return {"status": "ok", "components": components} + + +# ── CrowdSec ──────────────────────────────────────────────────────────── + + +@router.get("/crowdsec/alerts") +async def crowdsec_alerts(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + return _run(["cscli", "alerts", "list", "-o", "json"]) + + +@router.get("/crowdsec/decisions") +async def crowdsec_decisions(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + return _run(["cscli", "decisions", "list", "-o", "json"]) + + +@router.get("/crowdsec/metrics") +async def crowdsec_metrics(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + return _run(["cscli", "metrics"]) + + +@router.post("/crowdsec/ban") +async def crowdsec_ban(request: Request): + await require_admin(request, "security.write", AdminRole.ADMIN) + try: + body = await request.json() + ip = body.get("ip", "").strip() + if not ip: + raise HTTPException(status_code=400, detail="ip is required") + duration = body.get("duration", "24h").strip() + return _run(["cscli", "decisions", "add", "--ip", ip, "--duration", duration]) + except HTTPException: + raise + except Exception as exc: + return {"error": str(exc), "status": "unavailable"} + + +# ── Fail2Ban ──────────────────────────────────────────────────────────── + + +@router.get("/fail2ban/status") +async def fail2ban_status(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + result = _run(["fail2ban-client", "status"]) + + if isinstance(result, dict) and "raw" in result: + raw = result["raw"] + jails: list[str] = [] + currently_failed = 0 + currently_banned = 0 + + for line in raw.split("\n"): + m = re.match(r"^`-\s+Jail list:\s+(.*)", line) + if m: + jails = [j.strip() for j in m.group(1).split(",") if j.strip()] + + statuses: dict[str, dict] = {} + for jail in jails: + jr = _run(["fail2ban-client", "status", jail]) + if isinstance(jr, dict) and "raw" in jr: + jraw = jr["raw"] + cf = re.search(r"Currently failed:\s+(\d+)", jraw) + cb = re.search(r"Currently banned:\s+(\d+)", jraw) + statuses[jail] = { + "failed": int(cf.group(1)) if cf else 0, + "banned": int(cb.group(1)) if cb else 0, + } + currently_failed += statuses[jail]["failed"] + currently_banned += statuses[jail]["banned"] + + return { + "jails": jails, + "jail_statuses": statuses, + "total_failed": currently_failed, + "total_banned": currently_banned, + } + + return result + + +@router.get("/fail2ban/bans") +async def fail2ban_bans(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + result = _run(["fail2ban-client", "status"]) + + if isinstance(result, dict) and "raw" in result: + raw = result["raw"] + jails: list[str] = [] + for line in raw.split("\n"): + m = re.match(r"^`-\s+Jail list:\s+(.*)", line) + if m: + jails = [j.strip() for j in m.group(1).split(",") if j.strip()] + + bans: dict[str, int] = {} + total = 0 + for jail in jails: + jr = _run(["fail2ban-client", "status", jail]) + if isinstance(jr, dict) and "raw" in jr: + cb = re.search(r"Currently banned:\s+(\d+)", jr["raw"]) + count = int(cb.group(1)) if cb else 0 + bans[jail] = count + total += count + + return { + "jails": jails, + "banned_counts": bans, + "total_banned": total, + } + + return result + + +@router.post("/fail2ban/unban") +async def fail2ban_unban(request: Request): + await require_admin(request, "security.write", AdminRole.ADMIN) + try: + body = await request.json() + jail = body.get("jail", "").strip() + ip = body.get("ip", "").strip() + if not jail or not ip: + raise HTTPException(status_code=400, detail="jail and ip are required") + return _run(["fail2ban-client", "set", jail, "unbanip", ip]) + except HTTPException: + raise + except Exception as exc: + return {"error": str(exc), "status": "unavailable"} + + +# ── Firewall (UFW) ────────────────────────────────────────────────────── + + +@router.get("/firewall/status") +async def firewall_status(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + return _run(["ufw", "status", "verbose"]) + + +@router.get("/firewall/rules") +async def firewall_rules(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + return _run(["ufw", "status", "numbered"]) + + +# ── Ports ─────────────────────────────────────────────────────────────── + + +@router.get("/ports") +async def ports_listening(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + result = _run(["ss", "-tlnp"]) + + if isinstance(result, dict) and "raw" in result: + raw = result["raw"] + ports = [] + for line in raw.split("\n"): + line = line.strip() + if not line or line.startswith("State") or line.startswith("Netid"): + continue + ports.append(line) + return {"listening": ports, "count": len(ports)} + + return result + + +@router.post("/ports/close") +async def ports_close(request: Request): + await require_admin(request, "security.write", AdminRole.ADMIN) + raise HTTPException( + status_code=501, + detail="Port closure via API is not implemented — too dangerous for automated control", + ) + + +# ── SSH Hardening ─────────────────────────────────────────────────────── + + +SSHD_CONFIG_PATH = "/etc/ssh/sshd_config" + + +@router.get("/ssh/hardening") +async def ssh_hardening(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + checks: dict[str, dict] = { + "PermitRootLogin": { + "recommended": "no", + "description": "Disable root SSH login", + }, + "PasswordAuthentication": { + "recommended": "no", + "description": "Disable password auth, use keys only", + }, + "Port": { + "recommended": "non-default (not 22)", + "description": "Use a non-standard SSH port", + }, + "Protocol": { + "recommended": "2", + "description": "Use SSH protocol version 2 only", + }, + "X11Forwarding": { + "recommended": "no", + "description": "Disable X11 forwarding unless needed", + }, + "AllowTcpForwarding": { + "recommended": "no", + "description": "Disable TCP forwarding unless needed", + }, + "PermitEmptyPasswords": { + "recommended": "no", + "description": "Disallow empty passwords", + }, + "MaxAuthTries": { + "recommended": "<= 3", + "description": "Limit authentication attempts", + }, + "PubkeyAuthentication": { + "recommended": "yes", + "description": "Enable public key authentication", + }, + } + + results: dict[str, dict] = {} + try: + with open(SSHD_CONFIG_PATH) as fh: + content = fh.read() + + for key, info in checks.items(): + m = re.search(rf"^\s*{key}\s+(.+)", content, re.MULTILINE | re.IGNORECASE) + current = m.group(1).strip() if m else "not set (using default)" + results[key] = { + "current": current, + "recommended": info["recommended"], + "description": info["description"], + } + + return {"config_path": SSHD_CONFIG_PATH, "settings": results} + except FileNotFoundError: + return { + "error": f"{SSHD_CONFIG_PATH} not found", + "status": "unavailable", + } + except Exception as exc: + return {"error": str(exc), "status": "unavailable"} + + +# ── Kernel Hardening ──────────────────────────────────────────────────── + + +KERNEL_CHECKS: dict[str, dict] = { + "/proc/sys/kernel/randomize_va_space": { + "recommended": "2", + "label": "ASLR (Address Space Layout Randomization)", + "values": {"0": "disabled", "1": "partial", "2": "full"}, + }, + "/proc/sys/net/ipv4/tcp_syncookies": { + "recommended": "1", + "label": "TCP SYN Cookies", + "values": {"0": "disabled", "1": "enabled"}, + }, + "/proc/sys/net/ipv4/ip_forward": { + "recommended": "0", + "label": "IP Forwarding", + "values": {"0": "disabled", "1": "enabled"}, + }, + "/proc/sys/kernel/kptr_restrict": { + "recommended": ">= 1", + "label": "Kernel Pointer Restriction", + "values": { + "0": "unrestricted", + "1": "restricted (kptr)", + "2": "hidden", + }, + }, + "/proc/sys/kernel/dmesg_restrict": { + "recommended": ">= 1", + "label": "dmesg Restriction", + "values": {"0": "unrestricted", "1": "restricted"}, + }, +} + + +@router.get("/kernel/hardening") +async def kernel_hardening(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + results: dict[str, dict] = {} + for path, info in KERNEL_CHECKS.items(): + try: + with open(path) as fh: + raw_val = fh.read().strip() + current_val = raw_val + current_label = info["values"].get(raw_val, f"unknown ({raw_val})") + results[info["label"]] = { + "path": path, + "current": current_val, + "current_label": current_label, + "recommended": info["recommended"], + } + except FileNotFoundError: + results[info["label"]] = { + "path": path, + "current": "unavailable", + "current_label": "sysctl not found", + "recommended": info["recommended"], + } + except Exception as exc: + results[info["label"]] = { + "path": path, + "current": "error", + "current_label": str(exc), + "recommended": info["recommended"], + } + + return {"kernel_hardening": results} + + +# ── Threats Summary ───────────────────────────────────────────────────── + + +@router.get("/threats/summary") +async def threats_summary(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + summary: dict = { + "crowdsec_alerts": 0, + "fail2ban_bans": 0, + "ufw_denies": 0, + } + + alerts = _run(["cscli", "alerts", "list", "-o", "json"]) + if isinstance(alerts, list): + summary["crowdsec_alerts"] = len(alerts) + + decisions = _run(["cscli", "decisions", "list", "-o", "json"]) + if isinstance(decisions, list): + summary["crowdsec_decisions"] = len(decisions) + + f2b = _run(["fail2ban-client", "status"]) + if isinstance(f2b, dict) and "raw" in f2b: + cb = re.search(r"Currently banned:\s+(\d+)", f2b["raw"]) + summary["fail2ban_bans"] = int(cb.group(1)) if cb else 0 + + ufw_log = _run(["grep", "UFW BLOCK", "/var/log/ufw.log"]) + if isinstance(ufw_log, dict) and "raw" in ufw_log: + lines = [l for line in ufw_log["raw"].split("\n") if line.strip()] + summary["ufw_block_events_logged"] = len(lines) + + return summary + + +# ── Audit ─────────────────────────────────────────────────────────────── + + +@router.get("/audit/recent") +async def audit_recent(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + return _run(["journalctl", "-n", "50", "-p", "3", "-o", "short-iso"]) + + +# ── Integrity ─────────────────────────────────────────────────────────── + + +@router.get("/integrity/check") +async def integrity_check(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + if os.path.exists("/usr/bin/aide") or os.path.exists("/usr/sbin/aide"): + aide_bin = "/usr/bin/aide" if os.path.exists("/usr/bin/aide") else "/usr/sbin/aide" + result = _run([aide_bin, "--check"], timeout=120) + return {"aide_installed": True, "result": result} + else: + return { + "aide_installed": False, + "message": "aide not installed", + "status": "unavailable", + } + + +# ── Rootkit ───────────────────────────────────────────────────────────── + + +@router.get("/rootkit/scan") +async def rootkit_scan(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + rkhunter_paths = [ + "/usr/bin/rkhunter", + "/usr/local/bin/rkhunter", + "/usr/sbin/rkhunter", + ] + rkhunter_bin = None + for p in rkhunter_paths: + if os.path.exists(p): + rkhunter_bin = p + break + + if rkhunter_bin: + result = _run([rkhunter_bin, "--check", "--sk"], timeout=300) + return { + "rkhunter_installed": True, + "binary": rkhunter_bin, + "result": result, + } + else: + return { + "rkhunter_installed": False, + "message": "rkhunter not installed", + "status": "unavailable", + } + + +# ── Connections ───────────────────────────────────────────────────────── + + +@router.get("/connections/active") +async def connections_active(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + result = _run(["ss", "-tn", "state", "established"]) + + if isinstance(result, dict) and "raw" in result: + raw = result["raw"] + lines = [line.strip() for line in raw.split("\n") if line.strip()] + header = lines[0] if lines else "" + connections = lines[1:] if len(lines) > 1 else [] + return { + "header": header, + "connections": connections, + "count": len(connections), + } + + return result + + +# ── Docker Security ───────────────────────────────────────────────────── + + +@router.get("/docker/security") +async def docker_security(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + result = _run(["docker", "info", "--format", "{{json .}}"]) + + if isinstance(result, dict): + if "error" in result: + return result + + security_options = result.get("SecurityOptions", []) + rootless = "rootless" in str(security_options) + + return { + "rootless": rootless, + "security_options": security_options, + "server_version": result.get("ServerVersion", "unknown"), + "operating_system": result.get("OperatingSystem", "unknown"), + "apparmor": result.get("AppArmorVersion", "not available"), + "cgroup_driver": result.get("CgroupDriver", "unknown"), + "cgroup_version": result.get("CgroupVersion", "unknown"), + "containerd_version": result.get("ContainerdVersion", "unknown"), + "runc_version": result.get("RuncVersion", "unknown"), + } + + return result diff --git a/app/routers/admin/intelligence.py b/app/routers/admin/intelligence.py new file mode 100644 index 0000000..30676be --- /dev/null +++ b/app/routers/admin/intelligence.py @@ -0,0 +1,177 @@ +""" +IntelligenceFeed Router — Darkroom Backend +============================================ +Aggregated intelligence feed from DataBus providers: + - news_intel → crypto news aggregator + - social_feed → social media intelligence + - ct_rundown → CT (crypto twitter) rundown + +Permissions: + - MODERATOR: dashboard.read + +Mounted at: /api/v1/admin/intelligence/* +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, Query, Request + +from app.domains.admin.core import AdminRole, require_admin + +logger = logging.getLogger("admin.intelligence") + +router = APIRouter(prefix="/api/v1/admin/intelligence", tags=["admin-intelligence"]) + +DEFAULT_FEED_LIMIT = 20 +MAX_FEED_LIMIT = 100 + + +def _parse_datetime(entry: dict, key: str = "published_at") -> datetime | None: + val = entry.get(key) or entry.get("timestamp") or entry.get("created_at") or "" + try: + if isinstance(val, (int, float)): + return datetime.fromtimestamp(val, tz=UTC) + if isinstance(val, str): + val = val.strip().replace("Z", "+00:00") + return datetime.fromisoformat(val) + except (ValueError, TypeError): + return None + return None + + +def _normalize_entry(entry: dict, source_type: str) -> dict: + return { + "source_type": source_type, + "title": entry.get("title") or entry.get("headline") or "", + "summary": entry.get("summary") + or entry.get("description") + or entry.get("content", "")[:500], + "url": entry.get("url") or entry.get("link") or "", + "published_at": entry.get("published_at") or entry.get("timestamp") or "", + "source_name": entry.get("source_name") or entry.get("source") or "", + "raw": entry, + } + + +async def _fetch_databus_safe(data_type: str, **kwargs) -> list[dict]: + try: + from app.databus import databus + + result = await databus.fetch(data_type, **kwargs) + if result and isinstance(result, dict): + if result.get("error"): + logger.debug("databus fetch %s error: %s", data_type, result.get("error")) + return [] + items = result.get("data") or result.get("items") or result.get("results") or [] + if isinstance(items, list): + return items + return [items] if items else [] + return [] + except Exception as e: + logger.warning("databus fetch %s failed: %s", data_type, e) + return [] + + +@router.get("/feed") +async def intelligence_feed( + request: Request, + limit: int = Query(DEFAULT_FEED_LIMIT, ge=1, le=MAX_FEED_LIMIT), +): + await require_admin(request, "dashboard.read", AdminRole.MODERATOR) + + try: + news = await _fetch_databus_safe("news_intel", limit=limit) + except Exception: + news = [] + + try: + social = await _fetch_databus_safe("social_feed", limit=limit) + except Exception: + social = [] + + try: + ct = await _fetch_databus_safe("ct_rundown", limit=limit) + except Exception: + ct = [] + + unified: list[dict] = [] + for entry in news: + unified.append(_normalize_entry(entry, "news_intel")) + for entry in social: + unified.append(_normalize_entry(entry, "social_feed")) + for entry in ct: + unified.append(_normalize_entry(entry, "ct_rundown")) + + unified.sort( + key=lambda e: _parse_datetime(e) or datetime.min.replace(tzinfo=UTC), + reverse=True, + ) + + return { + "feed": unified[:limit], + "count": len(unified[:limit]), + "total": len(unified), + "sources": { + "news_intel": len(news), + "social_feed": len(social), + "ct_rundown": len(ct), + }, + } + + +@router.get("/latest") +async def intelligence_latest(request: Request): + await require_admin(request, "dashboard.read", AdminRole.MODERATOR) + + results: dict = {} + + try: + from app.databus import databus + + market = await databus.fetch("market_overview") + if market and not market.get("error"): + results["market_overview"] = market + except Exception as e: + logger.debug("market_overview fetch failed: %s", e) + + try: + from app.databus import databus + + trending = await databus.fetch("trending") + if trending and not trending.get("error"): + results["trending"] = trending + except Exception as e: + logger.debug("trending fetch failed: %s", e) + + try: + news_items = await _fetch_databus_safe("news_intel", limit=5) + results["news_headlines"] = [_normalize_entry(n, "news_intel") for n in news_items] + except Exception: + results["news_headlines"] = [] + + return {"latest": results, "timestamp": datetime.now(UTC).isoformat()} + + +@router.get("/health") +async def intelligence_health(): + return { + "status": "ok", + "modules": { + "feed": "GET /api/v1/admin/intelligence/feed", + "latest": "GET /api/v1/admin/intelligence/latest", + }, + "databus_sources": [ + "news_intel", + "social_feed", + "ct_rundown", + "market_overview", + "trending", + ], + "permissions_required": { + "feed": "MODERATOR + dashboard.read", + "latest": "MODERATOR + dashboard.read", + }, + } diff --git a/app/routers/admin/portfolio.py b/app/routers/admin/portfolio.py new file mode 100644 index 0000000..77bd257 --- /dev/null +++ b/app/routers/admin/portfolio.py @@ -0,0 +1,158 @@ +""" +PortfolioTracker Admin Router — Darkroom Backend +================================================= +Admin endpoints for tracking portfolio wallets and balances. +All endpoints require admin authentication via X-Admin-Session header. + +Permissions: + - MODERATOR: read portfolio, list wallets + - ADMIN: add/remove wallets + +Mounted at: /api/v1/admin/portfolio/* +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.domains.admin.core import AdminRole, require_admin +from app.domains.databus.core import databus +from app.routers.admin._supabase import get_supabase_client + +logger = logging.getLogger("admin.portfolio") + +router = APIRouter(prefix="/api/v1/admin/portfolio", tags=["admin-portfolio"]) + + +class WalletIn(BaseModel): + address: str = Field(..., min_length=1, max_length=200) + chain: str = Field(..., min_length=1, max_length=50) + label: str = Field("", max_length=200) + + +@router.get("/overview") +async def portfolio_overview(request: Request, chain: str = Query(None)): + await require_admin(request, "dashboard.read", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + result = await supabase.table("admin_portfolio").select("*").execute() + wallets = result.data or [] + + if chain: + wallets = [w for w in wallets if w.get("chain") == chain] + + enriched: list[dict] = [] + balance_errors: list[dict] = [] + + for w in wallets: + entry = dict(w) + try: + raw = await databus.fetch("wallet_tokens", address=w["address"]) + if raw: + entry["tokens"] = raw.get("tokens", raw.get("result", raw)) + entry["balance_source"] = raw.get("source", "databus") + else: + entry["tokens"] = None + entry["balance_source"] = "unavailable" + except Exception as exc: + logger.warning("Balance fetch failed for %s: %s", w["address"], exc) + entry["tokens"] = None + entry["balance_error"] = str(exc) + balance_errors.append({"address": w["address"], "error": str(exc)}) + + enriched.append(entry) + + return { + "wallets": enriched, + "total": len(enriched), + "with_balances": sum(1 for e in enriched if e.get("tokens")), + "balance_errors": len(balance_errors), + "errors": balance_errors[:10], + } + + +@router.get("/wallets") +async def list_wallets( + request: Request, + chain: str = Query(None), + limit: int = Query(50, ge=1, le=200), +): + await require_admin(request, "dashboard.read", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + query = ( + supabase.table("admin_portfolio").select("*").order("created_at", desc=True).limit(limit) + ) + + if chain: + query = query.eq("chain", chain) + + result = await query.execute() + rows = result.data or [] + + return { + "wallets": rows, + "total": len(rows), + "filters": {"chain": chain}, + } + + +@router.post("/wallets") +async def add_wallet(request: Request, wallet: WalletIn): + await require_admin(request, "dashboard.write", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + payload = { + "address": wallet.address, + "chain": wallet.chain, + "label": wallet.label, + "created_at": datetime.now(UTC).isoformat(), + } + + try: + result = await supabase.table("admin_portfolio").insert(payload).execute() + inserted = result.data[0] if result.data else payload + logger.info("Portfolio wallet added: %s (%s)", wallet.address, wallet.chain) + return {"status": "added", "wallet": inserted} + except Exception as e: + try: + result = await supabase.table("admin_portfolio").insert(payload).execute() + inserted = result.data[0] if result.data else payload + return {"status": "added", "wallet": inserted} + except Exception as inner_e: + logger.error("Failed to insert portfolio wallet: %s / %s", e, inner_e) + raise HTTPException(status_code=500, detail=f"Failed to add wallet: {e!s}") from e + + +@router.delete("/wallets/{wallet_id}") +async def remove_wallet(request: Request, wallet_id: str): + await require_admin(request, "dashboard.write", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + existing = await supabase.table("admin_portfolio").select("id").eq("id", wallet_id).execute() + if not existing.data: + raise HTTPException(status_code=404, detail=f"Wallet {wallet_id} not found in portfolio") + + await supabase.table("admin_portfolio").delete().eq("id", wallet_id).execute() + return {"status": "deleted", "wallet_id": wallet_id} + + +@router.get("/health") +async def portfolio_health(): + return { + "status": "ok", + "module": "admin-portfolio", + "auth_system": "domain_admin_rbac", + "permissions_required": { + "overview": "MODERATOR + dashboard.read", + "list_wallets": "MODERATOR + dashboard.read", + "add_wallet": "MODERATOR + dashboard.write", + "remove_wallet": "MODERATOR + dashboard.write", + }, + } diff --git a/app/routers/admin/revenue.py b/app/routers/admin/revenue.py new file mode 100644 index 0000000..d0ca32e --- /dev/null +++ b/app/routers/admin/revenue.py @@ -0,0 +1,106 @@ +""" +RevenueDashboard Admin Router — Darkroom Backend +================================================= +Admin endpoints for x402 revenue tracking and transparency. +All endpoints require admin authentication via X-Admin-Session header. + +Permissions: + - MODERATOR: read revenue dashboards + - ADMIN: full financial access + +Mounted at: /api/v1/admin/revenue/* +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query, Request + +from app.domains.admin.core import AdminRole, require_admin + +logger = logging.getLogger("admin.revenue") + +router = APIRouter(prefix="/api/v1/admin/revenue", tags=["admin-revenue"]) + +X402_STUB = { + "total_revenue": 0, + "active_tools": 0, + "total_transactions": 0, + "monthly_revenue": 0, + "tools": [], + "note": "x402 analytics backend pending — contact engineering", +} + + +@router.get("/dashboard") +async def revenue_dashboard(request: Request): + await require_admin(request, "financial.read", AdminRole.MODERATOR) + + return { + **X402_STUB, + "dashboard": "revenue-overview", + "period": "all-time", + } + + +@router.get("/ledger") +async def payment_ledger( + request: Request, + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + await require_admin(request, "financial.read", AdminRole.ADMIN) + + return { + "ledger": [], + "total": 0, + "limit": limit, + "offset": offset, + "note": "Payment ledger backend pending — contact engineering", + } + + +@router.get("/transparency") +async def revenue_transparency(request: Request): + await require_admin(request, "financial.read", AdminRole.MODERATOR) + + return { + **X402_STUB, + "report_type": "transparency", + "generated_at": None, + "breakdown": { + "subscription": 0, + "pay_per_use": 0, + "affiliate": 0, + "referral": 0, + }, + } + + +@router.get("/tools-catalog") +async def tools_catalog(request: Request): + await require_admin(request, "financial.read", AdminRole.MODERATOR) + + return { + "tools": [], + "total": 0, + "categories": [], + "note": "x402 tools catalog pending — contact engineering", + } + + +@router.get("/health") +async def revenue_health(): + return { + "status": "ok", + "module": "admin-revenue", + "auth_system": "domain_admin_rbac", + "x402_backend": "stub — pending integration", + "permissions_required": { + "dashboard": "MODERATOR + financial.read", + "ledger": "ADMIN + financial.read", + "transparency": "MODERATOR + financial.read", + "tools_catalog": "MODERATOR + financial.read", + }, + } diff --git a/app/routers/admin/scans.py b/app/routers/admin/scans.py new file mode 100644 index 0000000..d92685e --- /dev/null +++ b/app/routers/admin/scans.py @@ -0,0 +1,279 @@ +# ruff: noqa: S110, S112 +""" +ScanResults Router — Darkroom Backend +====================================== +Scanner data querying from Redis. All scan results are stored in Redis +under key patterns scan:*, token_scan:*, scanner:result:*. + +Permissions: + - ADMIN: read scan results, run batch scans (security.read / security.write) + +Mounted at: /api/v1/admin/scans/* +""" + +from __future__ import annotations + +import json +import logging +import os +import time + +from fastapi import APIRouter, HTTPException, Query, Request + +from app.domains.admin.core import AdminRole, require_admin + +try: + import redis.asyncio as aioredis + + REDIS_AVAILABLE = True +except ImportError: + REDIS_AVAILABLE = False + +logger = logging.getLogger("admin.scans") + +router = APIRouter(prefix="/api/v1/admin/scans", tags=["admin-scans"]) + +SCAN_KEY_PATTERNS = ["scan:*", "token_scan:*", "scanner:result:*", "rmi:state:*:scan:*"] + + +def _redis_client(): + if not REDIS_AVAILABLE: + return None + return aioredis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + +async def _redis_scan_keys(pattern: str, limit: int = 200) -> list[dict]: + r = _redis_client() + if not r: + return [] + + results = [] + try: + async for key in r.scan_iter(match=pattern, count=100): + try: + val = await r.get(key) + if val: + try: + results.append({"key": key, "value": json.loads(val)}) + except json.JSONDecodeError: + results.append({"key": key, "value": val}) + except Exception: + pass + if len(results) >= limit: + break + except Exception as e: + logger.warning("redis scan failed pattern=%s: %s", pattern, e) + return results + + +@router.get("/scans") +async def list_scans( + request: Request, + limit: int = Query(100, ge=1, le=1000), + pattern: str = Query("", description="Filter by key pattern prefix"), +): + await require_admin(request, "security.read", AdminRole.ADMIN) + + all_results = [] + patterns = SCAN_KEY_PATTERNS + if pattern: + patterns = [f"{pattern}*"] + + for pat in patterns: + items = await _redis_scan_keys(pat, limit=limit) + all_results.extend(items) + if len(all_results) >= limit: + break + + return {"scans": all_results[:limit], "count": len(all_results[:limit]), "limit": limit} + + +@router.get("/scans/stats") +async def scan_stats(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + r = _redis_client() + if not r: + return {"error": "redis_unavailable"} + + all_scans = [] + for pat in SCAN_KEY_PATTERNS: + all_scans.extend(await _redis_scan_keys(pat, limit=2000)) + + tiers = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0} + total = len(all_scans) + + for scan in all_scans: + val = scan.get("value", {}) + if isinstance(val, dict): + risk = (val.get("risk") or val.get("risk_tier") or val.get("score") or "").lower() + tier = val.get("tier", "").lower() + combined = risk or tier + else: + combined = "" + + if "critical" in combined: + tiers["critical"] += 1 + elif "high" in combined or "danger" in combined: + tiers["high"] += 1 + elif "medium" in combined or "moderate" in combined or "warning" in combined: + tiers["medium"] += 1 + elif "low" in combined or "safe" in combined: + tiers["low"] += 1 + else: + tiers["unknown"] += 1 + + return {"total_scans": total, "by_risk_tier": tiers} + + +@router.get("/scans/{scan_id}") +async def get_scan(request: Request, scan_id: str): + await require_admin(request, "security.read", AdminRole.ADMIN) + + r = _redis_client() + if not r: + raise HTTPException(status_code=503, detail="Redis unavailable") + + for pat in SCAN_KEY_PATTERNS: + pattern = pat.replace("*", f"*{scan_id}*") + try: + async for key in r.scan_iter(match=pattern, count=50): + val = await r.get(key) + if val: + try: + return {"scan_id": scan_id, "key": key, "value": json.loads(val)} + except json.JSONDecodeError: + return {"scan_id": scan_id, "key": key, "value": val} + except Exception as e: + logger.debug("scan lookup error pattern=%s: %s", pat, e) + continue + + raise HTTPException(status_code=404, detail=f"Scan {scan_id} not found") + + +@router.post("/scans/batch") +async def batch_scan(request: Request): + body = await request.json() + addresses = body.get("addresses", []) + chain = body.get("chain", "solana") + + await require_admin(request, "security.write", AdminRole.ADMIN) + + if not addresses or not isinstance(addresses, list): + raise HTTPException(status_code=422, detail="addresses list required") + + logger.info("batch_scan addresses=%d chain=%s", len(addresses), chain) + + results = [] + for addr in addresses[:50]: + try: + from app.databus import databus + + scan_result = await databus.fetch("scanner", address=addr, chain=chain) + result_entry = {"address": addr, "chain": chain, "result": scan_result, "status": "ok"} + except Exception as e: + result_entry = {"address": addr, "chain": chain, "status": "error", "error": str(e)} + results.append(result_entry) + + return {"batch_results": results, "count": len(results), "chain": chain} + + +@router.get("/scans/{scan_id}/report") +async def scan_report(request: Request, scan_id: str, format: str = Query("json")): + await require_admin(request, "security.read", AdminRole.ADMIN) + + r = _redis_client() + if not r: + raise HTTPException(status_code=503, detail="Redis unavailable") + + scan_data = None + for pat in SCAN_KEY_PATTERNS: + pattern = pat.replace("*", f"*{scan_id}*") + try: + async for key in r.scan_iter(match=pattern, count=50): + val = await r.get(key) + if val: + try: + scan_data = json.loads(val) + except json.JSONDecodeError: + scan_data = {"raw": val} + break + except Exception: + continue + if scan_data: + break + + if not scan_data: + raise HTTPException(status_code=404, detail=f"Scan {scan_id} not found") + + if format == "markdown": + md = _build_markdown_report(scan_data, scan_id) + return {"report": md, "format": "markdown", "scan_id": scan_id} + + return {"report": scan_data, "format": "json", "scan_id": scan_id} + + +def _build_markdown_report(data: dict, scan_id: str) -> str: + lines = [ + f"# Scan Report: `{scan_id}`", + "", + f"**Generated:** {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}", + "", + "## Summary", + "", + ] + + if isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, (dict, list)): + lines.append(f"- **{k}**: (nested, see below)") + else: + lines.append(f"- **{k}**: {v}") + else: + lines.append(f"```\n{data}\n```") + + for k, v in data.items(): + if isinstance(v, dict): + lines.append(f"\n### {k}") + for sk, sv in v.items(): + lines.append(f"- **{sk}**: {sv}") + elif isinstance(v, list): + lines.append(f"\n### {k}") + for item in v[:20]: + lines.append(f"- {item}") + + return "\n".join(lines) + + +@router.get("/scans/health") +async def scans_health(): + r = _redis_client() + redis_status = "disconnected" + if r: + try: + await r.ping() + redis_status = "connected" + except Exception: + pass + + total_keys = 0 + if r and redis_status == "connected": + for pat in SCAN_KEY_PATTERNS[:2]: + try: + async for _ in r.scan_iter(match=pat, count=10): + total_keys += 1 + except Exception: + pass + + return { + "status": "ok", + "redis": redis_status, + "scan_key_patterns": SCAN_KEY_PATTERNS, + "estimated_scan_keys": total_keys, + "redis_available": REDIS_AVAILABLE, + } diff --git a/app/routers/admin/security_dashboard.py b/app/routers/admin/security_dashboard.py new file mode 100644 index 0000000..c7fb5d5 --- /dev/null +++ b/app/routers/admin/security_dashboard.py @@ -0,0 +1,356 @@ +# ruff: noqa: S110 +# ruff: noqa: S603 +""" +SecurityCommand Router — Darkroom Backend +========================================== +Security dashboard API for threat alerts, entity labels, and security scoring. + +Permissions: + - ADMIN: security.read (alerts, stats, entity lookup) + - ADMIN: security.write (future mutating operations) + +Mounted at: /api/v1/admin/security/* +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request + +from app.domains.admin.core import AdminRole, require_admin + +try: + import redis.asyncio as aioredis + + REDIS_AVAILABLE = True +except ImportError: + REDIS_AVAILABLE = False + +logger = logging.getLogger("admin.security") + +router = APIRouter(prefix="/api/v1/admin/security", tags=["admin-security"]) + +SECURITY_KEY_PATTERNS = [ + "threat:*", + "security:alert:*", + "crowdsec:*", + "fail2ban:*", + "audit_log:*", +] + + +def _redis_client(): + if not REDIS_AVAILABLE: + return None + return aioredis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + +def _run_check(cmd: list[str], timeout: int = 3) -> dict: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return { + "active": result.returncode == 0, + "output": result.stdout.strip()[:500], + "rc": result.returncode, + } + except subprocess.TimeoutExpired: + return {"active": False, "error": "timeout"} + except FileNotFoundError: + return {"active": False, "error": "not_found"} + except Exception as e: + return {"active": False, "error": str(e)} + + +@router.get("/dashboard/alerts") +async def security_alerts( + request: Request, + limit: int = Query(50, ge=1, le=500), +): + await require_admin(request, "security.read", AdminRole.ADMIN) + + r = _redis_client() + if not r: + return {"alerts": [], "count": 0, "error": "redis_unavailable"} + + alerts = [] + for pat in SECURITY_KEY_PATTERNS: + try: + async for key in r.scan_iter(match=pat, count=100): + if len(alerts) >= limit: + break + val = await r.get(key) + if val: + try: + alerts.append({"key": key, "data": json.loads(val)}) + except json.JSONDecodeError: + alerts.append({"key": key, "data": val}) + except Exception as e: + logger.debug("alert scan error pattern=%s: %s", pat, e) + + return {"alerts": alerts, "count": len(alerts)} + + +@router.get("/dashboard/stats") +async def security_stats(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + r = _redis_client() + + crowdsec_alerts = 0 + fail2ban_bans = 0 + ufw_rules = 0 + + if r: + try: + async for _ in r.scan_iter(match="crowdsec:*", count=500): + crowdsec_alerts += 1 + except Exception: + pass + try: + async for _ in r.scan_iter(match="fail2ban:*", count=500): + fail2ban_bans += 1 + except Exception: + pass + + ufw_result = _run_check(["ufw", "status", "numbered"]) + if ufw_result.get("active") and ufw_result.get("output"): + ufw_rules = ufw_result["output"].count("[") + else: + alt_result = _run_check(["sudo", "ufw", "status", "numbered"]) + if alt_result.get("active") and alt_result.get("output"): + ufw_rules = alt_result["output"].count("[") + + return { + "crowdsec_alerts": crowdsec_alerts, + "fail2ban_bans": fail2ban_bans, + "ufw_rules_count": ufw_rules, + "redis_available": r is not None and r is not False, + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/dashboard/score") +async def security_score(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + checks: dict[str, Any] = {} + + checks["firewall_active"] = _run_check(["ufw", "status"]) + checks["crowdsec_active"] = _run_check(["systemctl", "is-active", "crowdsec"]) + checks["fail2ban_active"] = _run_check(["systemctl", "is-active", "fail2ban"]) + + ssh_check = _run_check(["sshd", "-T"]) + ssh_hardened = False + if ssh_check.get("active") and ssh_check.get("output"): + output = ssh_check["output"].lower() + ssh_hardened = ( + "permitrootlogin no" in output + or "passwordauthentication no" in output + or ("permitrootlogin" in output and "passwordauthentication" in output) + ) + checks["ssh_hardened"] = {"active": ssh_hardened, "detail": ssh_check} + + kernel_check = _run_check( + [ + "sysctl", + "-n", + "kernel.kptr_restrict", + "kernel.dmesg_restrict", + "kernel.randomize_va_space", + ] + ) + kernel_hardened = False + if kernel_check.get("active") and kernel_check.get("output"): + vals = kernel_check["output"].strip().split("\n") + if len(vals) >= 3: + try: + kptr = int(vals[0].strip()) + dmesg = int(vals[1].strip()) + aslr = int(vals[2].strip()) + kernel_hardened = kptr >= 1 and dmesg >= 1 and aslr >= 2 + except (ValueError, IndexError): + pass + checks["kernel_hardened"] = {"active": kernel_hardened, "detail": kernel_check} + + weights = { + "firewall_active": 25, + "crowdsec_active": 20, + "fail2ban_active": 20, + "ssh_hardened": 20, + "kernel_hardened": 15, + } + + score = 0 + max_score = sum(weights.values()) + for check_name, weight in weights.items(): + check_data = checks.get(check_name, {}) + if isinstance(check_data, dict): + active = check_data.get("active", False) + else: + active = bool(check_data) + if active: + score += weight + + return { + "security_score": round(score / max_score * 100), + "max_score": 100, + "raw_score": score, + "raw_max": max_score, + "checks": checks, + "grading": "A" + if score / max_score >= 0.9 + else "B" + if score / max_score >= 0.75 + else "C" + if score / max_score >= 0.6 + else "D" + if score / max_score >= 0.4 + else "F", + } + + +@router.get("/entity/search") +async def entity_search( + request: Request, + q: str = Query("", description="Search query for entity labels"), +): + await require_admin(request, "security.read", AdminRole.ADMIN) + + if not q: + raise HTTPException(status_code=422, detail="q parameter required") + + results: dict[str, Any] = {} + + try: + from app.databus import databus + + databus_result = await databus.fetch("wallet_labels", address=q) + if databus_result and not databus_result.get("error"): + results["databus"] = databus_result + except Exception as e: + logger.debug("databus wallet_labels failed for %s: %s", q, e) + + r = _redis_client() + if r: + try: + labels_from_redis = [] + for pat in [f"label:*{q}*", f"entity:*{q}*", f"wallet:*{q}*"]: + async for key in r.scan_iter(match=pat, count=50): + val = await r.get(key) + if val: + try: + labels_from_redis.append({"key": key, "data": json.loads(val)}) + except json.JSONDecodeError: + labels_from_redis.append({"key": key, "data": val}) + if labels_from_redis: + results["redis"] = labels_from_redis + except Exception as e: + logger.debug("redis entity search failed: %s", e) + + return {"query": q, "results": results, "source": list(results.keys())} + + +@router.get("/entity/stats") +async def entity_stats(request: Request): + await require_admin(request, "security.read", AdminRole.ADMIN) + + r = _redis_client() + if not r: + return {"redis_available": False, "label_keys": 0} + + label_key_count = 0 + entity_key_count = 0 + try: + async for _ in r.scan_iter(match="label:*", count=500): + label_key_count += 1 + async for _ in r.scan_iter(match="entity:*", count=500): + entity_key_count += 1 + except Exception: + pass + + return { + "redis_available": True, + "label_keys": label_key_count, + "entity_keys": entity_key_count, + "total": label_key_count + entity_key_count, + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/entity/label/{address}") +async def entity_label(request: Request, address: str): + await require_admin(request, "security.read", AdminRole.ADMIN) + + results: dict[str, Any] = {} + try: + from app.databus import databus + + databus_result = await databus.fetch("wallet_labels", address=address) + if databus_result and not databus_result.get("error"): + results["databus"] = databus_result + except Exception as e: + logger.debug("databus wallet_labels failed for %s: %s", address, e) + + r = _redis_client() + if r: + try: + redis_matches = [] + for pat in [f"label:*{address}*", f"entity:*{address}*"]: + async for key in r.scan_iter(match=pat, count=20): + val = await r.get(key) + if val: + try: + redis_matches.append({"key": key, "data": json.loads(val)}) + except json.JSONDecodeError: + redis_matches.append({"key": key, "data": val}) + if redis_matches: + results["redis"] = redis_matches + except Exception: + pass + + if not results: + raise HTTPException(status_code=404, detail=f"No labels found for {address}") + + return {"address": address, "results": results} + + +@router.get("/health") +async def security_health(): + r = _redis_client() + redis_status = "disconnected" + if r: + try: + await r.ping() + redis_status = "connected" + except Exception: + pass + + ufw_check = _run_check(["ufw", "status"]) + crowdsec_check = _run_check(["systemctl", "is-active", "crowdsec"]) + fail2ban_check = _run_check(["systemctl", "is-active", "fail2ban"]) + + return { + "status": "ok", + "redis": redis_status, + "redis_available": REDIS_AVAILABLE, + "services": { + "ufw": ufw_check.get("active", False), + "crowdsec": crowdsec_check.get("active", False), + "fail2ban": fail2ban_check.get("active", False), + }, + "permissions_required": { + "alerts": "ADMIN + security.read", + "entity_search": "ADMIN + security.read", + }, + } diff --git a/app/routers/admin/watchlist.py b/app/routers/admin/watchlist.py new file mode 100644 index 0000000..fba259c --- /dev/null +++ b/app/routers/admin/watchlist.py @@ -0,0 +1,194 @@ +""" +TokenWatchlist Admin Router — Darkroom Backend +=============================================== +Admin endpoints for managing token watchlist and price alerts. +All endpoints require admin authentication via X-Admin-Session header. + +Permissions: + - MODERATOR: read watchlist, acknowledge alerts + - ADMIN: add/remove tokens from watchlist + +Mounted at: /api/v1/admin/watchlist/* +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.domains.admin.core import AdminRole, require_admin +from app.routers.admin._supabase import get_supabase_client + +logger = logging.getLogger("admin.watchlist") + +router = APIRouter(prefix="/api/v1/admin/watchlist", tags=["admin-watchlist"]) + + +class WatchlistTokenIn(BaseModel): + chain: str = Field(..., min_length=1, max_length=50) + address: str = Field(..., min_length=1, max_length=100) + label: str = Field("", max_length=200) + alert_threshold: float = Field(0.0, description="Price change % threshold for alerting") + + +class AlertAck(BaseModel): + note: str = Field("", max_length=500) + + +@router.get("/tokens") +async def list_watchlist_tokens( + request: Request, + chain: str = Query(None), + limit: int = Query(50, ge=1, le=200), +): + await require_admin(request, "dashboard.read", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + query = ( + supabase.table("admin_watchlist").select("*").order("created_at", desc=True).limit(limit) + ) + + if chain: + query = query.eq("chain", chain) + + result = await query.execute() + rows = result.data or [] + + return { + "tokens": rows, + "total": len(rows), + "filters": {"chain": chain}, + } + + +@router.post("/tokens") +async def add_watchlist_token(request: Request, token: WatchlistTokenIn): + await require_admin(request, "dashboard.write", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + payload = { + "chain": token.chain, + "address": token.address, + "label": token.label, + "alert_threshold": token.alert_threshold, + "active": True, + "created_at": datetime.now(UTC).isoformat(), + } + + try: + result = await supabase.table("admin_watchlist").insert(payload).execute() + inserted = result.data[0] if result.data else payload + logger.info("Watchlist token added: %s/%s", token.chain, token.address) + return {"status": "added", "token": inserted} + except Exception as e: + try: + await supabase.rpc("create_admin_watchlist_table").execute() + except Exception: + pass + try: + result = await supabase.table("admin_watchlist").insert(payload).execute() + inserted = result.data[0] if result.data else payload + return {"status": "added", "token": inserted} + except Exception as inner_e: + logger.error("Failed to insert watchlist token: %s / %s", e, inner_e) + raise HTTPException(status_code=500, detail=f"Failed to add token: {e!s}") from e + + +@router.delete("/tokens/{token_id}") +async def remove_watchlist_token(request: Request, token_id: str): + await require_admin(request, "dashboard.write", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + existing = await supabase.table("admin_watchlist").select("id").eq("id", token_id).execute() + if not existing.data: + raise HTTPException(status_code=404, detail=f"Token {token_id} not found in watchlist") + + await supabase.table("admin_watchlist").delete().eq("id", token_id).execute() + return {"status": "deleted", "token_id": token_id} + + +@router.get("/alerts") +async def list_watchlist_alerts( + request: Request, + status: str = Query("pending", description="Filter: pending, acknowledged, dismissed"), + limit: int = Query(50, ge=1, le=200), +): + await require_admin(request, "dashboard.read", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + try: + query = ( + supabase.table("admin_watchlist_alerts") + .select("*") + .order("created_at", desc=True) + .limit(limit) + ) + if status: + query = query.eq("status", status) + result = await query.execute() + rows = result.data or [] + except Exception: + rows = [] + + return { + "alerts": rows, + "total": len(rows), + "status_filter": status, + } + + +@router.post("/alerts/{alert_id}/ack") +async def acknowledge_alert(request: Request, alert_id: str, body: AlertAck = AlertAck()): + admin = await require_admin(request, "dashboard.write", AdminRole.MODERATOR) + + supabase = await get_supabase_client() + + try: + existing = ( + await supabase.table("admin_watchlist_alerts").select("id").eq("id", alert_id).execute() + ) + if not existing.data: + raise HTTPException(status_code=404, detail=f"Alert {alert_id} not found") + + await ( + supabase.table("admin_watchlist_alerts") + .update( + { + "status": "acknowledged", + "acknowledged_at": datetime.now(UTC).isoformat(), + "acknowledged_by": admin.get("admin", {}).get("id", "unknown"), + "ack_note": body.note, + } + ) + .eq("id", alert_id) + .execute() + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to acknowledge alert %s: %s", alert_id, e) + raise HTTPException(status_code=500, detail=f"Failed to acknowledge: {e!s}") from e + + return {"status": "acknowledged", "alert_id": alert_id} + + +@router.get("/health") +async def watchlist_health(): + return { + "status": "ok", + "module": "admin-watchlist", + "auth_system": "domain_admin_rbac", + "permissions_required": { + "list_tokens": "MODERATOR + dashboard.read", + "add_token": "MODERATOR + dashboard.write", + "remove_token": "MODERATOR + dashboard.write", + "list_alerts": "MODERATOR + dashboard.read", + "ack_alert": "MODERATOR + dashboard.write", + }, + } diff --git a/app/routers/admin_control.py b/app/routers/admin_control.py index b11673c..3211f7d 100644 --- a/app/routers/admin_control.py +++ b/app/routers/admin_control.py @@ -21,7 +21,20 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin-control"]) # ── Auth helper ── async def _verify_admin(request: Request): - """Verify admin access with API key.""" + """Verify admin access — accepts X-Admin-Session (RBAC) OR X-Admin-Key (legacy).""" + # Try X-Admin-Session first (domain RBAC token) + session_token = request.headers.get("X-Admin-Session", "") + if session_token: + try: + from app.domains.admin.core import SessionManager + + session = await SessionManager.validate_session(session_token) + if session: + return True + except Exception: # noqa: S110 + pass + + # Fall back to X-Admin-Key (legacy static key) admin_key = os.getenv("ADMIN_API_KEY", "") if not admin_key: raise HTTPException(status_code=401, detail="Admin API key not configured") diff --git a/app/routers/admin_extensions.py b/app/routers/admin_extensions.py index 4b38d81..4584ada 100644 --- a/app/routers/admin_extensions.py +++ b/app/routers/admin_extensions.py @@ -30,7 +30,20 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin-extensions"]) # ── Auth helper ── async def _verify_admin(request: Request): - """Verify admin access with API key.""" + """Verify admin access — accepts X-Admin-Session (RBAC) OR X-Admin-Key (legacy).""" + # Try X-Admin-Session first (domain RBAC token) + session_token = request.headers.get("X-Admin-Session", "") + if session_token: + try: + from app.domains.admin.core import SessionManager + + session = await SessionManager.validate_session(session_token) + if session: + return True + except Exception: # noqa: S110 + pass + + # Fall back to X-Admin-Key (legacy static key) admin_key = os.getenv("ADMIN_API_KEY", "") if not admin_key: raise HTTPException(status_code=401, detail="Admin API key not configured") @@ -516,10 +529,21 @@ async def cron_resume(request: Request, job_id: str, _=Depends(_verify_admin)): def _get_brightdata_proxy(): - """Return Bright Data residential proxy config for Dify access.""" + """Get Bright Data proxy config from environment.""" + user = os.getenv("BRIGHTDATA_USER", "") + password = os.getenv("BRIGHTDATA_PASSWORD", "") + host = os.getenv("BRIGHTDATA_HOST", "brd.superproxy.io") + port = os.getenv("BRIGHTDATA_PORT", "33335") + + if not user or not password: + return { + "enabled": False, + "proxy": None, + } + return { - "proxy": "http://brd-customer-hl_c51d0c6f-zone-residential_proxy1-country-us:lig96713z47s@brd.superproxy.io:33335", - "proxy_auth": None, # auth is embedded in the URL + "enabled": True, + "proxy": f"http://{user}:{password}@{host}:{port}", } @@ -527,10 +551,13 @@ def _brightdata_client(timeout: int = 30): """Create an httpx client routed through Bright Data residential proxy.""" import httpx - proxy_url = "http://brd-customer-hl_c51d0c6f-zone-residential_proxy1-country-us:lig96713z47s@brd.superproxy.io:33335" + proxy_config = _get_brightdata_proxy() + if not proxy_config["enabled"]: + return httpx.AsyncClient(timeout=timeout) + return httpx.AsyncClient( timeout=timeout, - proxy=proxy_url, + proxy=proxy_config["proxy"], verify=False, # noqa: S501 # Bright Data handles TLS termination ) diff --git a/app/routers/social_api.py b/app/routers/social_api.py new file mode 100644 index 0000000..a7917d3 --- /dev/null +++ b/app/routers/social_api.py @@ -0,0 +1,218 @@ +""" +Social API Router — GotoSocial Bridge +===================================== +Bridges frontend social features to the GotoSocial ActivityPub server +running at social.degenfeed.xyz (127.0.0.1:8082). + +GET endpoints proxy public data anonymously. +POST/PUT endpoints forward auth to GotoSocial. +""" + +from __future__ import annotations + +import logging + +import httpx +from fastapi import APIRouter, HTTPException, Query, Request + +logger = logging.getLogger("social_api") + +router = APIRouter(prefix="/api/v1/social", tags=["social"]) + +GOTOSOCIAL_URL = "http://127.0.0.1:8082" + + +def _get_auth_header(request: Request) -> dict[str, str]: + """Extract auth header from incoming request to forward to GotoSocial.""" + auth = request.headers.get("Authorization", "") + if auth: + return {"Authorization": auth} + return {} + + +@router.get("/posts") +async def list_posts( + request: Request, + limit: int = Query(20, ge=1, le=100), + local: bool = Query(True), +): + """Get public timeline from GotoSocial.""" + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.get( + f"{GOTOSOCIAL_URL}/api/v1/timelines/public", + params={"limit": limit, "local": str(local).lower()}, + headers=_get_auth_header(request), + ) + if r.status_code == 200: + data = r.json() + return {"posts": data, "total": len(data)} + logger.warning(f"GotoSocial /timelines/public returned {r.status_code}") + return {"posts": [], "total": 0} + except Exception as e: + logger.warning(f"GotoSocial bridge failed: {e}") + return {"posts": [], "total": 0, "offline": True} + + +@router.post("/posts") +async def create_post(request: Request): + """Post a status to GotoSocial.""" + body = await request.json() + content = body.get("content", "") or body.get("title", "") + if not content: + raise HTTPException(400, "content is required") + + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post( + f"{GOTOSOCIAL_URL}/api/v1/statuses", + json={"status": content}, + headers=_get_auth_header(request), + ) + if r.status_code in (200, 201): + return r.json() + detail = r.text[:200] + logger.warning(f"GotoSocial POST /statuses returned {r.status_code}: {detail}") + raise HTTPException(r.status_code, detail=detail or "GotoSocial rejected post") + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"GotoSocial bridge error: {e}") from e + + +@router.get("/posts/{post_id}") +async def get_post(post_id: str, request: Request): + """Get a single status from GotoSocial.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get( + f"{GOTOSOCIAL_URL}/api/v1/statuses/{post_id}", + headers=_get_auth_header(request), + ) + if r.status_code == 200: + return r.json() + if r.status_code == 404: + raise HTTPException(404, "Post not found") + raise HTTPException(502, f"GotoSocial returned {r.status_code}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"GotoSocial bridge error: {e}") from e + + +@router.post("/posts/{post_id}/comments") +async def add_comment(post_id: str, request: Request): + """Reply to a status on GotoSocial.""" + body = await request.json() + content = body.get("content", "") + if not content: + raise HTTPException(400, "content is required") + + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post( + f"{GOTOSOCIAL_URL}/api/v1/statuses", + json={"status": content, "in_reply_to_id": post_id}, + headers=_get_auth_header(request), + ) + if r.status_code in (200, 201): + return r.json() + raise HTTPException(r.status_code, detail=r.text[:200]) + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"GotoSocial bridge error: {e}") from e + + +@router.post("/posts/{post_id}/like") +async def like_post(post_id: str, request: Request): + """Favourite a status on GotoSocial.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{GOTOSOCIAL_URL}/api/v1/statuses/{post_id}/favourite", + headers=_get_auth_header(request), + ) + if r.status_code in (200, 201): + return r.json() + raise HTTPException(r.status_code, detail=r.text[:200]) + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"GotoSocial bridge error: {e}") from e + + +@router.get("/trending") +async def trending(window: str = Query("24h")): + """Get trending tags from GotoSocial.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(f"{GOTOSOCIAL_URL}/api/v1/trends/statuses") + if r.status_code == 200: + return {"trending": r.json(), "window": window} + r2 = await client.get(f"{GOTOSOCIAL_URL}/api/v1/trends/tags") + if r2.status_code == 200: + return {"trending": r2.json(), "window": window, "type": "tags"} + return {"trending": [], "window": window} + except Exception: + return {"trending": [], "window": window, "offline": True} + + +@router.get("/users/{user_id}") +async def get_user(user_id: str): + """Get account profile from GotoSocial.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(f"{GOTOSOCIAL_URL}/api/v1/accounts/{user_id}") + if r.status_code == 200: + return r.json() + raise HTTPException(404, "Account not found") + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"GotoSocial bridge error: {e}") from e + + +@router.put("/users/me") +async def update_profile(request: Request): + """Update own GotoSocial profile.""" + body = await request.json() + patch: dict = {} + if body.get("username"): + patch["display_name"] = body["username"] + if body.get("bio"): + patch["note"] = body["bio"] + if body.get("avatar"): + patch["avatar"] = body["avatar"] + + if not patch: + raise HTTPException(400, "No fields to update") + + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.patch( + f"{GOTOSOCIAL_URL}/api/v1/accounts/update_credentials", + json=patch, + headers=_get_auth_header(request), + ) + if r.status_code == 200: + return r.json() + raise HTTPException(r.status_code, detail=r.text[:200]) + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"GotoSocial bridge error: {e}") from e + + +@router.get("/health") +async def health(): + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{GOTOSOCIAL_URL}/api/v1/instance") + return { + "status": "ok", + "gotoSocial": "connected", + "version": r.json().get("version", "unknown"), + } + except Exception as e: + return {"status": "degraded", "gotoSocial": str(e)}