- FastAPI async gateway with httpx proxying to multiple upstreams - SQLite database with SQLAlchemy ORM - Admin console: manage services, users, API keys, endpoint access - Per-key, per-endpoint granular access control - OpenAPI document sync and caching (5-minute TTL) - Request/response logging with full transaction inspection - In-memory rate limiting (per-key, fixed-window) - Tiered log retention (7d payloads, 90d rows, incremental vacuum) - TLS verification toggle per service (for self-signed certificates) - Service connectivity validation with automatic endpoint refresh - Request browser with filters and deep-link inspection - Docker setup with persistent volume - Modal forms for create/edit flows Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
424 lines
17 KiB
Python
424 lines
17 KiB
Python
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import config, discovery, security
|
|
from app.admin.deps import current_user
|
|
from app.database import get_db
|
|
from app.models import ApiKey, Endpoint, RequestLog, Service, User
|
|
|
|
router = APIRouter(prefix="/admin")
|
|
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
|
|
|
|
# Slugs the proxy catch-all must never claim
|
|
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"}
|
|
|
|
|
|
def build_tree(endpoints: list[Endpoint]) -> dict:
|
|
"""Nest endpoints by path segment for the hierarchical access picker.
|
|
Node: {name, children: {segment: node}, endpoints: [Endpoint]}.
|
|
Chains of empty single-child nodes are compressed ('api' + 'v1' -> 'api/v1')."""
|
|
root = {"name": "", "children": {}, "endpoints": []}
|
|
for e in endpoints:
|
|
node = root
|
|
for part in (p for p in e.path.split("/") if p):
|
|
node = node["children"].setdefault(
|
|
part, {"name": part, "children": {}, "endpoints": []})
|
|
node["endpoints"].append(e)
|
|
|
|
def compress(node: dict) -> None:
|
|
for key in list(node["children"]):
|
|
child = node["children"][key]
|
|
while not child["endpoints"] and len(child["children"]) == 1:
|
|
(grandchild,) = child["children"].values()
|
|
child["name"] = child["name"] + "/" + grandchild["name"]
|
|
child["endpoints"] = grandchild["endpoints"]
|
|
child["children"] = grandchild["children"]
|
|
compress(child)
|
|
if child["name"] != key:
|
|
node["children"][child["name"]] = node["children"].pop(key)
|
|
|
|
compress(root)
|
|
return root
|
|
|
|
|
|
def render(request: Request, name: str, user: User | None = None, **ctx):
|
|
return templates.TemplateResponse(
|
|
request, name, {"user": user, "active": name.split(".")[0], **ctx}
|
|
)
|
|
|
|
|
|
def _redirect(url: str) -> RedirectResponse:
|
|
return RedirectResponse(url, status_code=303)
|
|
|
|
|
|
# ---------- auth ----------
|
|
|
|
@router.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
return render(request, "login.html")
|
|
|
|
|
|
@router.post("/login")
|
|
def login(request: Request, username: str = Form(...), password: str = Form(...),
|
|
db: Session = Depends(get_db)):
|
|
user = db.query(User).filter(User.username == username).one_or_none()
|
|
if not user or not user.is_active or not security.verify_password(password, user.password_hash):
|
|
return render(request, "login.html", error="Invalid username or password.")
|
|
response = _redirect("/admin")
|
|
response.set_cookie(
|
|
config.SESSION_COOKIE,
|
|
security.create_session_token(user.id),
|
|
max_age=config.SESSION_MAX_AGE,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
return response
|
|
|
|
|
|
@router.get("/logout")
|
|
def logout():
|
|
response = _redirect("/admin/login")
|
|
response.delete_cookie(config.SESSION_COOKIE)
|
|
return response
|
|
|
|
|
|
# ---------- dashboard ----------
|
|
|
|
@router.get("", response_class=HTMLResponse)
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def dashboard(request: Request, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
recent = (
|
|
db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(15).all()
|
|
)
|
|
return render(request, "dashboard.html", user, recent=recent)
|
|
|
|
|
|
# ---------- services & endpoints ----------
|
|
|
|
@router.get("/services", response_class=HTMLResponse)
|
|
def services_page(request: Request, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
services = db.query(Service).order_by(Service.name).all()
|
|
counts = dict(
|
|
db.query(RequestLog.service_id, func.count(RequestLog.id))
|
|
.group_by(RequestLog.service_id).all()
|
|
)
|
|
return render(request, "services.html", user, services=services, counts=counts)
|
|
|
|
|
|
@router.post("/services")
|
|
def create_service(name: str = Form(...), slug: str = Form(...), base_url: str = Form(...),
|
|
description: str = Form(""), timeout_seconds: float = Form(30.0),
|
|
verify_tls: bool = Form(False),
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
slug = slug.strip().lower()
|
|
if slug in RESERVED_SLUGS:
|
|
raise HTTPException(400, f"Slug '{slug}' is reserved by the gateway itself.")
|
|
if db.query(Service).filter(Service.slug == slug).count():
|
|
raise HTTPException(400, f"Slug '{slug}' is already taken.")
|
|
service = Service(name=name.strip(), slug=slug, base_url=base_url.strip().rstrip("/"),
|
|
description=description.strip(), timeout_seconds=timeout_seconds,
|
|
verify_tls=verify_tls)
|
|
db.add(service)
|
|
db.commit()
|
|
# The page auto-validates the new service (which also caches its endpoints).
|
|
return _redirect(f"/admin/services?validate={service.id}")
|
|
|
|
|
|
@router.post("/services/{service_id}/update")
|
|
def update_service(service_id: int, name: str = Form(...), base_url: str = Form(...),
|
|
description: str = Form(""), timeout_seconds: float = Form(30.0),
|
|
verify_tls: bool = Form(False),
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
service = db.get(Service, service_id)
|
|
if not service:
|
|
raise HTTPException(404)
|
|
service.name, service.base_url = name.strip(), base_url.strip().rstrip("/")
|
|
service.description, service.timeout_seconds = description.strip(), timeout_seconds
|
|
service.verify_tls = verify_tls
|
|
db.commit()
|
|
return _redirect(f"/admin/services?validate={service.id}")
|
|
|
|
|
|
@router.post("/services/{service_id}/validate")
|
|
def validate_service(service_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Probe the upstream; if it publishes an OpenAPI document this also
|
|
refreshes the endpoint cache."""
|
|
service = db.get(Service, service_id)
|
|
if not service:
|
|
raise HTTPException(404)
|
|
return discovery.validate_service(db, service)
|
|
|
|
|
|
@router.post("/services/{service_id}/toggle")
|
|
def toggle_service(service_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
service = db.get(Service, service_id)
|
|
if not service:
|
|
raise HTTPException(404)
|
|
service.is_active = not service.is_active
|
|
db.commit()
|
|
return _redirect("/admin/services")
|
|
|
|
|
|
@router.post("/services/{service_id}/delete")
|
|
def delete_service(service_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
service = db.get(Service, service_id)
|
|
if service:
|
|
db.delete(service)
|
|
db.commit()
|
|
return _redirect("/admin/services")
|
|
|
|
|
|
# ---------- users ----------
|
|
|
|
@router.get("/users", response_class=HTMLResponse)
|
|
def users_page(request: Request, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
users = db.query(User).order_by(User.username).all()
|
|
return render(request, "users.html", user, users=users)
|
|
|
|
|
|
@router.post("/users")
|
|
def create_user(username: str = Form(...), password: str = Form(...),
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
username = username.strip()
|
|
if db.query(User).filter(User.username == username).count():
|
|
raise HTTPException(400, f"Username '{username}' is already taken.")
|
|
db.add(User(username=username, password_hash=security.hash_password(password)))
|
|
db.commit()
|
|
return _redirect("/admin/users")
|
|
|
|
|
|
@router.post("/users/{user_id}/toggle")
|
|
def toggle_user(user_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
target = db.get(User, user_id)
|
|
if not target:
|
|
raise HTTPException(404)
|
|
if target.id == user.id:
|
|
raise HTTPException(400, "You cannot deactivate your own account.")
|
|
target.is_active = not target.is_active
|
|
db.commit()
|
|
return _redirect("/admin/users")
|
|
|
|
|
|
@router.post("/users/{user_id}/password")
|
|
def reset_password(user_id: int, password: str = Form(...),
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
target = db.get(User, user_id)
|
|
if not target:
|
|
raise HTTPException(404)
|
|
target.password_hash = security.hash_password(password)
|
|
db.commit()
|
|
return _redirect("/admin/users")
|
|
|
|
|
|
@router.post("/users/{user_id}/delete")
|
|
def delete_user(user_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
if user_id == user.id:
|
|
raise HTTPException(400, "You cannot delete your own account.")
|
|
target = db.get(User, user_id)
|
|
if target:
|
|
db.delete(target)
|
|
db.commit()
|
|
return _redirect("/admin/users")
|
|
|
|
|
|
# ---------- API keys / endpoint access ----------
|
|
|
|
@router.get("/keys", response_class=HTMLResponse)
|
|
def keys_page(request: Request, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
services = db.query(Service).order_by(Service.name).all()
|
|
# Rendered straight from the database — the OpenAPI sync runs in the
|
|
# background via /admin/api/endpoints/sync, triggered by the page's JS.
|
|
trees = {s.id: build_tree(s.endpoints) for s in services}
|
|
synced = {s.id: discovery.spec_status(s.id) for s in services}
|
|
keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
|
|
users = db.query(User).filter(User.is_active).order_by(User.username).all()
|
|
new_key = request.query_params.get("new_key")
|
|
return render(request, "keys.html", user, keys=keys, users=users,
|
|
services=services, trees=trees, synced=synced, new_key=new_key)
|
|
|
|
|
|
@router.get("/api/endpoints/sync")
|
|
def sync_endpoints(force: bool = False, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Refresh all endpoint catalogs from their OpenAPI documents (TTL-cached).
|
|
The keys page calls this in the background and reloads if anything changed."""
|
|
changed_any = False
|
|
specs = {}
|
|
for service in db.query(Service).order_by(Service.name).all():
|
|
found, changed = discovery.sync_service(db, service, force=force)
|
|
specs[service.id] = found
|
|
changed_any = changed_any or changed
|
|
return {"changed": changed_any, "specs": specs}
|
|
|
|
|
|
@router.post("/keys")
|
|
def create_key(request: Request, name: str = Form(...), user_id: int = Form(...),
|
|
rate_limit_per_minute: int = Form(60),
|
|
endpoint_ids: list[int] = Form([]),
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
owner = db.get(User, user_id)
|
|
if not owner:
|
|
raise HTTPException(400, "Unknown user.")
|
|
plain, prefix, key_hash = security.generate_api_key()
|
|
key = ApiKey(user_id=owner.id, name=name.strip(), prefix=prefix, key_hash=key_hash,
|
|
rate_limit_per_minute=max(0, rate_limit_per_minute))
|
|
key.endpoints = db.query(Endpoint).filter(
|
|
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
|
|
db.add(key)
|
|
db.commit()
|
|
# Shown once on the next page load; never stored in plain text.
|
|
return _redirect(f"/admin/keys?new_key={plain}")
|
|
|
|
|
|
@router.post("/keys/{key_id}/access")
|
|
def update_key_access(key_id: int, endpoint_ids: list[int] = Form([]),
|
|
rate_limit_per_minute: int = Form(60),
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
key = db.get(ApiKey, key_id)
|
|
if not key:
|
|
raise HTTPException(404)
|
|
key.endpoints = db.query(Endpoint).filter(
|
|
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
|
|
key.rate_limit_per_minute = max(0, rate_limit_per_minute)
|
|
db.commit()
|
|
return _redirect("/admin/keys")
|
|
|
|
|
|
@router.post("/keys/{key_id}/toggle")
|
|
def toggle_key(key_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
key = db.get(ApiKey, key_id)
|
|
if not key:
|
|
raise HTTPException(404)
|
|
key.is_active = not key.is_active
|
|
db.commit()
|
|
return _redirect("/admin/keys")
|
|
|
|
|
|
@router.post("/keys/{key_id}/delete")
|
|
def delete_key(key_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
key = db.get(ApiKey, key_id)
|
|
if key:
|
|
db.delete(key)
|
|
db.commit()
|
|
return _redirect("/admin/keys")
|
|
|
|
|
|
# ---------- request browser ----------
|
|
|
|
PAGE_SIZE = 50
|
|
|
|
|
|
def _int_or_none(value: str | None) -> int | None:
|
|
"""HTML GET forms submit empty strings for untouched fields — treat
|
|
anything non-numeric as 'no filter' instead of a validation error."""
|
|
try:
|
|
return int(value) if value else None
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
@router.get("/requests", response_class=HTMLResponse)
|
|
def requests_page(request: Request, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
service_id: str | None = None, user_id: str | None = None,
|
|
key_id: str | None = None, status_class: str | None = None,
|
|
q: str | None = None, page: str | None = None):
|
|
service_id = _int_or_none(service_id)
|
|
user_id = _int_or_none(user_id)
|
|
key_id = _int_or_none(key_id)
|
|
page = _int_or_none(page) or 1
|
|
query = db.query(RequestLog)
|
|
if service_id:
|
|
query = query.filter(RequestLog.service_id == service_id)
|
|
if key_id:
|
|
query = query.filter(RequestLog.api_key_id == key_id)
|
|
if user_id:
|
|
query = query.join(ApiKey, RequestLog.api_key_id == ApiKey.id).filter(
|
|
ApiKey.user_id == user_id)
|
|
if status_class in ("2", "3", "4", "5"):
|
|
low = int(status_class) * 100
|
|
query = query.filter(RequestLog.status_code >= low,
|
|
RequestLog.status_code < low + 100)
|
|
if q:
|
|
query = query.filter(RequestLog.path.contains(q))
|
|
|
|
total = query.count()
|
|
page = max(1, page)
|
|
logs = (query.order_by(RequestLog.timestamp.desc())
|
|
.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all())
|
|
|
|
services = db.query(Service).order_by(Service.name).all()
|
|
users = db.query(User).order_by(User.username).all()
|
|
keys = db.query(ApiKey).order_by(ApiKey.name).all()
|
|
return render(request, "requests.html", user, logs=logs, total=total,
|
|
page=page, pages=max(1, -(-total // PAGE_SIZE)),
|
|
services=services, users=users, keys=keys,
|
|
f={"service_id": service_id, "user_id": user_id, "key_id": key_id,
|
|
"status_class": status_class or "", "q": q or ""})
|
|
|
|
|
|
def _pretty_json(text: str) -> str:
|
|
import json
|
|
try:
|
|
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
|
|
except (ValueError, TypeError):
|
|
return text
|
|
|
|
|
|
@router.get("/requests/{log_id}/data")
|
|
def request_data(log_id: int, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Everything the inline request inspector needs, as JSON."""
|
|
log = db.get(RequestLog, log_id)
|
|
if not log:
|
|
raise HTTPException(404)
|
|
return {
|
|
"id": log.id,
|
|
"time": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"status": log.status_code,
|
|
"latency_ms": round(log.latency_ms, 1),
|
|
"method": log.method,
|
|
"path": log.path,
|
|
"query_string": log.query_string,
|
|
"service": log.service.name if log.service else None,
|
|
"slug": log.service.slug if log.service else None,
|
|
"forwarded_to": (log.service.base_url + log.path +
|
|
("?" + log.query_string if log.query_string else ""))
|
|
if log.service else None,
|
|
"endpoint": f"{log.endpoint.method} {log.endpoint.path}" if log.endpoint else None,
|
|
"endpoint_description": log.endpoint.description if log.endpoint else "",
|
|
"key": log.api_key.name if log.api_key else None,
|
|
"key_prefix": log.api_key.prefix if log.api_key else "",
|
|
"user": log.api_key.user.username if log.api_key else None,
|
|
"client_ip": log.client_ip,
|
|
"request_body": _pretty_json(log.request_body),
|
|
"response_body": _pretty_json(log.response_body),
|
|
}
|
|
|
|
|
|
# ---------- monitoring ----------
|
|
|
|
@router.get("/monitoring", response_class=HTMLResponse)
|
|
def monitoring_page(request: Request, user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
logs = db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(100).all()
|
|
services = db.query(Service).order_by(Service.name).all()
|
|
users = db.query(User).order_by(User.username).all()
|
|
keys = db.query(ApiKey).order_by(ApiKey.name).all()
|
|
return render(request, "monitoring.html", user, logs=logs,
|
|
services=services, users=users, keys=keys)
|