Initial commit: API gateway with admin console
- 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>
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import config, proxy, retention, security
|
||||
from app.admin import routes as admin_routes
|
||||
from app.admin import stats as admin_stats
|
||||
from app.admin.deps import LoginRequired, login_redirect_handler
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.models import User
|
||||
|
||||
|
||||
def enable_incremental_vacuum() -> None:
|
||||
"""Deleted log rows should hand their pages back to the filesystem;
|
||||
switching auto_vacuum requires a one-time VACUUM (cannot run in a
|
||||
transaction, hence the raw connection)."""
|
||||
raw = engine.raw_connection()
|
||||
try:
|
||||
cursor = raw.cursor()
|
||||
mode = cursor.execute("PRAGMA auto_vacuum").fetchone()[0]
|
||||
if mode != 2: # 2 = INCREMENTAL
|
||||
cursor.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
||||
raw.commit()
|
||||
cursor.execute("VACUUM")
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def _table_exists(db, name: str) -> bool:
|
||||
return db.execute(text(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=:n"
|
||||
), {"n": name}).first() is not None
|
||||
|
||||
|
||||
def migrate_schema() -> None:
|
||||
"""Bring older databases up to date (SQLite)."""
|
||||
with engine.begin() as conn:
|
||||
cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(request_logs)")]
|
||||
if cols and "endpoint_id" not in cols:
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE request_logs ADD COLUMN endpoint_id INTEGER "
|
||||
"REFERENCES endpoints(id) ON DELETE SET NULL"
|
||||
)
|
||||
for column, ddl in (
|
||||
("query_string", "VARCHAR(2048) DEFAULT ''"),
|
||||
("request_body", "TEXT DEFAULT ''"),
|
||||
("response_body", "TEXT DEFAULT ''"),
|
||||
):
|
||||
if cols and column not in cols:
|
||||
conn.exec_driver_sql(f"ALTER TABLE request_logs ADD COLUMN {column} {ddl}")
|
||||
service_cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(services)")]
|
||||
if service_cols and "verify_tls" not in service_cols:
|
||||
conn.exec_driver_sql("ALTER TABLE services ADD COLUMN verify_tls BOOLEAN DEFAULT 1")
|
||||
|
||||
|
||||
def seed_and_migrate() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Role-era databases: replace role->service grants with per-key grants
|
||||
# on a service-wide catch-all endpoint, then drop the role tables.
|
||||
if _table_exists(db, "roles"):
|
||||
db.execute(text(
|
||||
"INSERT INTO endpoints (service_id, method, path, description) "
|
||||
"SELECT id, '*', '/**', 'Migrated: full service access' FROM services"
|
||||
))
|
||||
if _table_exists(db, "role_service_access"):
|
||||
db.execute(text(
|
||||
"INSERT OR IGNORE INTO key_endpoint_access (api_key_id, endpoint_id) "
|
||||
"SELECT k.id, e.id "
|
||||
"FROM api_keys k "
|
||||
"JOIN users u ON u.id = k.user_id "
|
||||
"JOIN role_service_access rsa ON rsa.role_id = u.role_id "
|
||||
"JOIN endpoints e ON e.service_id = rsa.service_id AND e.path = '/**'"
|
||||
))
|
||||
db.execute(text("DROP TABLE role_service_access"))
|
||||
if _table_exists(db, "role_permissions"):
|
||||
db.execute(text("DROP TABLE role_permissions"))
|
||||
db.execute(text("ALTER TABLE users DROP COLUMN role_id"))
|
||||
db.execute(text("DROP TABLE roles"))
|
||||
db.commit()
|
||||
print("Migrated role-based access to per-key endpoint grants.")
|
||||
|
||||
if db.query(User).count() == 0:
|
||||
db.add(User(
|
||||
username=config.DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=security.hash_password(config.DEFAULT_ADMIN_PASSWORD),
|
||||
))
|
||||
db.commit()
|
||||
print(f"Seeded admin user '{config.DEFAULT_ADMIN_USERNAME}' "
|
||||
f"(password: '{config.DEFAULT_ADMIN_PASSWORD}' — change it after first login).")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(engine)
|
||||
migrate_schema()
|
||||
seed_and_migrate()
|
||||
enable_incremental_vacuum()
|
||||
retention_task = asyncio.create_task(retention.retention_loop())
|
||||
yield
|
||||
retention_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await retention_task
|
||||
await proxy.close_client()
|
||||
|
||||
|
||||
app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan)
|
||||
app.add_exception_handler(LoginRequired, login_redirect_handler)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root():
|
||||
return RedirectResponse("/admin")
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(admin_routes.router)
|
||||
app.include_router(admin_stats.router)
|
||||
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
|
||||
# The proxy catch-all (/{slug}/...) must come last so it never shadows
|
||||
# /admin, /static, /docs or /health.
|
||||
app.include_router(proxy.router)
|
||||
Reference in New Issue
Block a user