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
This commit is contained in:
parent
30a6508893
commit
3bebd2b297
16 changed files with 2865 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -89,3 +89,4 @@ data/papers/
|
|||
*.hdf5
|
||||
main.py.bak
|
||||
ruff.toml
|
||||
rmi-rag/
|
||||
|
|
|
|||
11
app/mount.py
11
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)
|
||||
|
|
|
|||
11
app/routers/admin/_supabase.py
Normal file
11
app/routers/admin/_supabase.py
Normal file
|
|
@ -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)
|
||||
178
app/routers/admin/alerts.py
Normal file
178
app/routers/admin/alerts.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
193
app/routers/admin/containers.py
Normal file
193
app/routers/admin/containers.py
Normal file
|
|
@ -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),
|
||||
}
|
||||
326
app/routers/admin/fleet.py
Normal file
326
app/routers/admin/fleet.py
Normal file
|
|
@ -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 '<<HOST>>' && hostname && echo '<<UPTIME>>' && uptime && echo '<<MEM>>' && free -h && echo '<<DISK>>' && df -h / && echo '<<LOAD>>' && uptime && echo '<<DOCKER>>' && 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 == "<<HOST>>":
|
||||
current_section = "host"
|
||||
elif line == "<<UPTIME>>":
|
||||
current_section = "uptime"
|
||||
elif line == "<<MEM>>":
|
||||
current_section = "mem"
|
||||
elif line == "<<DISK>>":
|
||||
current_section = "disk"
|
||||
elif line == "<<LOAD>>":
|
||||
current_section = "load"
|
||||
elif line == "<<DOCKER>>":
|
||||
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",
|
||||
},
|
||||
}
|
||||
610
app/routers/admin/infra_defense.py
Normal file
610
app/routers/admin/infra_defense.py
Normal file
|
|
@ -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
|
||||
177
app/routers/admin/intelligence.py
Normal file
177
app/routers/admin/intelligence.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
158
app/routers/admin/portfolio.py
Normal file
158
app/routers/admin/portfolio.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
106
app/routers/admin/revenue.py
Normal file
106
app/routers/admin/revenue.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
279
app/routers/admin/scans.py
Normal file
279
app/routers/admin/scans.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
356
app/routers/admin/security_dashboard.py
Normal file
356
app/routers/admin/security_dashboard.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
194
app/routers/admin/watchlist.py
Normal file
194
app/routers/admin/watchlist.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
218
app/routers/social_api.py
Normal file
218
app/routers/social_api.py
Normal file
|
|
@ -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)}
|
||||
Loading…
Add table
Add a link
Reference in a new issue