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:
Crypto Rug Munch 2026-07-08 23:01:05 +02:00
parent 30a6508893
commit 3bebd2b297
16 changed files with 2865 additions and 7 deletions

1
.gitignore vendored
View file

@ -89,3 +89,4 @@ data/papers/
*.hdf5
main.py.bak
ruff.toml
rmi-rag/

View file

@ -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)

View 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
View 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",
},
}

View 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
View 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",
},
}

View 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

View 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",
},
}

View 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",
},
}

View 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
View 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,
}

View 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",
},
}

View 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",
},
}

View file

@ -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")

View file

@ -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
View 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)}