main.py (5146 lines, 106 routes) becomes an assembly only: one package per domain — core, files, settings, dashboards, conversations, diff, notifications, forms, runs, accounts, workers, models, cron, agents, projects, services, goals, memories, plans, templates — each exposing an APIRouter; the flat domain modules move into their package behind a barrel that keeps the old `import conversations` / `import projects` spellings. The shared singletons (store, meta_store, hub, indexer, …) are built once by core.state.build_state() and attached to app.state.ai; routes take them as the `deps: State` dependency and helpers as an explicit `deps: AppState`. conversations/pricing.py carries the per-model rates out of the parser. Verified: route table and OpenAPI byte-identical; 90 read endpoints golden-diffed against the monolith on a copy of the live data (identical); write routes smoke-tested; 66 backend tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
101 lines
4.4 KiB
Python
101 lines
4.4 KiB
Python
"""The trusted-caller gate: every /api request must come from Traefik, the
|
|
docker gateway, localhost or a caller holding INTERNAL_API_TOKEN."""
|
|
|
|
import os
|
|
import pathlib
|
|
import socket
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
# ── trusted-caller gate ──────────────────────────────────────────────────────
|
|
# The backend has no auth of its own: the browser reaches it through Traefik
|
|
# (behind Authelia) and host tooling through the 127.0.0.1-published port. But
|
|
# the container also sits on the shared `main` docker network, where any other
|
|
# container could hit it directly — and a PUT into `.claude/` (hooks) or a
|
|
# /api/spawn is host-level code execution. So every /api request must come from
|
|
# a trusted peer: Traefik (the Authelia-guarded edge), the docker gateway (how
|
|
# host-originated connections to the published port appear), localhost, or a
|
|
# caller presenting the shared INTERNAL_API_TOKEN. Only /api/health stays open
|
|
# (deploy probes). Static assets are public-shell only, so they stay open too.
|
|
INTERNAL_API_TOKEN = os.environ.get("INTERNAL_API_TOKEN", "")
|
|
# A read-ONLY API key: a caller presenting it (X-API-Key) may hit **safe**
|
|
# (GET/HEAD/OPTIONS) /api/* routes only — every mutating method is refused. This
|
|
# is what the desk phone holds to fetch unread notifications: it must never be
|
|
# able to spawn a session or PUT into .claude/ (that's host-level code exec).
|
|
# Comma-separated to allow more than one key. Empty ⇒ feature off.
|
|
READONLY_API_KEYS = frozenset(
|
|
k.strip() for k in os.environ.get("READONLY_API_KEY", "").split(",")
|
|
if k.strip())
|
|
# Methods a read-only key is allowed to use.
|
|
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
TRAEFIK_HOST = os.environ.get("TRAEFIK_HOST", "traefik")
|
|
_TRUSTED_TTL_S = 30.0
|
|
_trusted_lock = threading.Lock()
|
|
_trusted_cache: tuple[float, frozenset[str]] = (0.0, frozenset())
|
|
|
|
|
|
def _default_gateway_ips() -> set[str]:
|
|
"""The container's default-gateway IP(s) (/proc/net/route, IPv4)."""
|
|
ips: set[str] = set()
|
|
try:
|
|
for line in pathlib.Path("/proc/net/route").read_text().splitlines()[1:]:
|
|
f = line.split()
|
|
if len(f) >= 3 and f[1] == "00000000": # default route
|
|
ips.add(socket.inet_ntoa(bytes.fromhex(f[2])[::-1]))
|
|
except (OSError, ValueError):
|
|
pass
|
|
return ips
|
|
|
|
|
|
def _trusted_ips() -> frozenset[str]:
|
|
"""Gateway + Traefik IPs, re-resolved at most every _TRUSTED_TTL_S."""
|
|
global _trusted_cache
|
|
now = time.monotonic()
|
|
with _trusted_lock:
|
|
ts, ips = _trusted_cache
|
|
if now - ts < _TRUSTED_TTL_S and ips:
|
|
return ips
|
|
fresh = {"127.0.0.1", "::1"} | _default_gateway_ips()
|
|
try:
|
|
for info in socket.getaddrinfo(TRAEFIK_HOST, None):
|
|
fresh.add(info[4][0])
|
|
except OSError:
|
|
pass
|
|
out = frozenset(fresh)
|
|
with _trusted_lock:
|
|
_trusted_cache = (now, out)
|
|
return out
|
|
|
|
|
|
async def _trusted_caller_gate(request: Request, call_next):
|
|
path = request.url.path
|
|
if path.startswith("/api/") and path != "/api/health":
|
|
# A read-only API key downgrades the caller to safe methods, whatever the
|
|
# source IP — the phone reaches us over a "trusted" host loopback but is
|
|
# only allowed to read (fetch notifications), never to spawn/mutate. So
|
|
# this check comes FIRST and, when the key matches, it decides the request
|
|
# outright (a read-only key on a POST is refused, not silently upgraded by
|
|
# a trusted IP).
|
|
api_key = request.headers.get("x-api-key", "")
|
|
if api_key and api_key in READONLY_API_KEYS:
|
|
if request.method not in _SAFE_METHODS:
|
|
return JSONResponse(
|
|
{"detail": "read-only api key: method not allowed"},
|
|
status_code=403)
|
|
return await call_next(request)
|
|
|
|
client = request.client.host if request.client else ""
|
|
if client not in _trusted_ips():
|
|
tok = request.headers.get("x-internal-auth", "")
|
|
if not (INTERNAL_API_TOKEN and tok == INTERNAL_API_TOKEN):
|
|
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
|
return await call_next(request)
|
|
|
|
|
|
def install(app: FastAPI) -> None:
|
|
"""Register the gate as the app's HTTP middleware."""
|
|
app.middleware("http")(_trusted_caller_gate)
|