feat(core): 初始化 Key-IP Sentinel 服务与部署骨架

- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构
- 实现反向代理、首用绑定、拦截告警、归档任务和管理接口
- 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
2026-03-04 00:18:33 +08:00
commit ab1bd90c65
50 changed files with 5645 additions and 0 deletions

49
app/api/auth.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from redis.asyncio import Redis
from app.config import Settings
from app.core.ip_utils import extract_client_ip
from app.core.security import (
clear_login_failures,
create_admin_jwt,
ensure_login_allowed,
register_login_failure,
verify_admin_password,
)
from app.dependencies import get_redis, get_settings
from app.schemas.auth import LoginRequest, TokenResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(
payload: LoginRequest,
request: Request,
settings: Settings = Depends(get_settings),
redis: Redis | None = Depends(get_redis),
) -> TokenResponse:
if redis is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Login service is unavailable because Redis is offline.",
)
client_ip = extract_client_ip(request, settings)
await ensure_login_allowed(redis, client_ip, settings)
if not verify_admin_password(payload.password, settings):
await register_login_failure(redis, client_ip, settings)
logger.warning("Admin login failed.", extra={"client_ip": client_ip})
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin password.")
await clear_login_failures(redis, client_ip)
token, expires_in = create_admin_jwt(settings)
logger.info("Admin login succeeded.", extra={"client_ip": client_ip})
return TokenResponse(access_token=token, expires_in=expires_in)

153
app/api/bindings.py Normal file
View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import String, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings
from app.core.ip_utils import extract_client_ip
from app.dependencies import get_binding_service, get_db_session, get_settings, require_admin
from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding
from app.schemas.binding import (
BindingActionRequest,
BindingIPUpdateRequest,
BindingItem,
BindingListResponse,
)
from app.services.binding_service import BindingService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api/bindings", tags=["bindings"], dependencies=[Depends(require_admin)])
def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> BindingItem:
return BindingItem(
id=binding.id,
token_display=binding.token_display,
bound_ip=str(binding.bound_ip),
status=binding.status,
status_label=binding_service.status_label(binding.status),
first_used_at=binding.first_used_at,
last_used_at=binding.last_used_at,
created_at=binding.created_at,
)
async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding:
binding = await session.get(TokenBinding, binding_id)
if binding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Binding was not found.")
return binding
def log_admin_action(request: Request, settings: Settings, action: str, binding_id: int) -> None:
logger.info(
"Admin binding action.",
extra={
"client_ip": extract_client_ip(request, settings),
"action": action,
"binding_id": binding_id,
},
)
@router.get("", response_model=BindingListResponse)
async def list_bindings(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
token_suffix: str | None = Query(default=None),
ip: str | None = Query(default=None),
status_filter: int | None = Query(default=None, alias="status"),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
) -> BindingListResponse:
statement = select(TokenBinding)
if token_suffix:
statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%"))
if ip:
statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%"))
if status_filter in {STATUS_ACTIVE, STATUS_BANNED}:
statement = statement.where(TokenBinding.status == status_filter)
total_result = await session.execute(select(func.count()).select_from(statement.subquery()))
total = int(total_result.scalar_one())
bindings = (
await session.scalars(
statement.order_by(TokenBinding.last_used_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
return BindingListResponse(
items=[to_binding_item(item, binding_service) for item in bindings],
total=total,
page=page,
page_size=page_size,
)
@router.post("/unbind")
async def unbind_token(
payload: BindingActionRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
token_hash = binding.token_hash
await session.delete(binding)
await session.commit()
await binding_service.invalidate_binding_cache(token_hash)
log_admin_action(request, settings, "unbind", payload.id)
return {"success": True}
@router.put("/ip")
async def update_bound_ip(
payload: BindingIPUpdateRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.bound_ip = payload.bound_ip
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "update_ip", payload.id)
return {"success": True}
@router.post("/ban")
async def ban_token(
payload: BindingActionRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_BANNED
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "ban", payload.id)
return {"success": True}
@router.post("/unban")
async def unban_token(
payload: BindingActionRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_ACTIVE
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "unban", payload.id)
return {"success": True}

109
app/api/dashboard.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import UTC, datetime, time, timedelta
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_binding_service, get_db_session, require_admin
from app.models.intercept_log import InterceptLog
from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding
from app.schemas.log import InterceptLogItem
from app.services.binding_service import BindingService
router = APIRouter(prefix="/admin/api", tags=["dashboard"], dependencies=[Depends(require_admin)])
class MetricSummary(BaseModel):
total: int
allowed: int
intercepted: int
class BindingSummary(BaseModel):
active: int
banned: int
class TrendPoint(BaseModel):
date: str
total: int
allowed: int
intercepted: int
class DashboardResponse(BaseModel):
today: MetricSummary
bindings: BindingSummary
trend: list[TrendPoint]
recent_intercepts: list[InterceptLogItem]
async def build_trend(
session: AsyncSession,
binding_service: BindingService,
) -> list[TrendPoint]:
series = await binding_service.get_metrics_window(days=7)
start_day = datetime.combine(datetime.now(UTC).date() - timedelta(days=6), time.min, tzinfo=UTC)
intercept_counts_result = await session.execute(
select(func.date(InterceptLog.intercepted_at), func.count())
.where(InterceptLog.intercepted_at >= start_day)
.group_by(func.date(InterceptLog.intercepted_at))
)
db_intercept_counts = {
row[0].isoformat(): int(row[1])
for row in intercept_counts_result.all()
}
trend: list[TrendPoint] = []
for item in series:
day = str(item["date"])
allowed = int(item["allowed"])
intercepted = max(int(item["intercepted"]), db_intercept_counts.get(day, 0))
total = max(int(item["total"]), allowed + intercepted)
trend.append(TrendPoint(date=day, total=total, allowed=allowed, intercepted=intercepted))
return trend
async def build_recent_intercepts(session: AsyncSession) -> list[InterceptLogItem]:
recent_logs = (
await session.scalars(select(InterceptLog).order_by(InterceptLog.intercepted_at.desc()).limit(10))
).all()
return [
InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
for item in recent_logs
]
@router.get("/dashboard", response_model=DashboardResponse)
async def get_dashboard(
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
) -> DashboardResponse:
trend = await build_trend(session, binding_service)
active_count = await session.scalar(
select(func.count()).select_from(TokenBinding).where(TokenBinding.status == STATUS_ACTIVE)
)
banned_count = await session.scalar(
select(func.count()).select_from(TokenBinding).where(TokenBinding.status == STATUS_BANNED)
)
recent_intercepts = await build_recent_intercepts(session)
today = trend[-1] if trend else TrendPoint(date=datetime.now(UTC).date().isoformat(), total=0, allowed=0, intercepted=0)
return DashboardResponse(
today=MetricSummary(total=today.total, allowed=today.allowed, intercepted=today.intercepted),
bindings=BindingSummary(active=int(active_count or 0), banned=int(banned_count or 0)),
trend=trend,
recent_intercepts=recent_intercepts,
)

107
app/api/logs.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import csv
import io
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import String, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db_session, require_admin
from app.models.intercept_log import InterceptLog
from app.schemas.log import InterceptLogItem, LogListResponse
router = APIRouter(prefix="/admin/api/logs", tags=["logs"], dependencies=[Depends(require_admin)])
def apply_log_filters(
statement,
token: str | None,
attempt_ip: str | None,
start_time: datetime | None,
end_time: datetime | None,
):
if token:
statement = statement.where(InterceptLog.token_display.ilike(f"%{token}%"))
if attempt_ip:
statement = statement.where(cast(InterceptLog.attempt_ip, String).ilike(f"%{attempt_ip}%"))
if start_time:
statement = statement.where(InterceptLog.intercepted_at >= start_time)
if end_time:
statement = statement.where(InterceptLog.intercepted_at <= end_time)
return statement
@router.get("", response_model=LogListResponse)
async def list_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
token: str | None = Query(default=None),
attempt_ip: str | None = Query(default=None),
start_time: datetime | None = Query(default=None),
end_time: datetime | None = Query(default=None),
session: AsyncSession = Depends(get_db_session),
) -> LogListResponse:
statement = apply_log_filters(select(InterceptLog), token, attempt_ip, start_time, end_time)
total_result = await session.execute(select(func.count()).select_from(statement.subquery()))
total = int(total_result.scalar_one())
logs = (
await session.scalars(
statement.order_by(InterceptLog.intercepted_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
return LogListResponse(
items=[
InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
for item in logs
],
total=total,
page=page,
page_size=page_size,
)
@router.get("/export")
async def export_logs(
token: str | None = Query(default=None),
attempt_ip: str | None = Query(default=None),
start_time: datetime | None = Query(default=None),
end_time: datetime | None = Query(default=None),
session: AsyncSession = Depends(get_db_session),
):
statement = apply_log_filters(select(InterceptLog), token, attempt_ip, start_time, end_time).order_by(
InterceptLog.intercepted_at.desc()
)
logs = (await session.scalars(statement)).all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
for item in logs:
writer.writerow(
[
item.id,
item.token_display,
str(item.bound_ip),
str(item.attempt_ip),
item.alerted,
item.intercepted_at.isoformat(),
]
)
filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv"
return StreamingResponse(
iter([buffer.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

77
app/api/settings.py Normal file
View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from redis.asyncio import Redis
from app.config import RUNTIME_SETTINGS_REDIS_KEY, RuntimeSettings, Settings
from app.core.ip_utils import extract_client_ip
from app.dependencies import get_redis, get_runtime_settings, get_settings, require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api/settings", tags=["settings"], dependencies=[Depends(require_admin)])
class SettingsResponse(BaseModel):
alert_webhook_url: str | None = None
alert_threshold_count: int = Field(ge=1)
alert_threshold_seconds: int = Field(ge=1)
archive_days: int = Field(ge=1)
failsafe_mode: Literal["open", "closed"]
class SettingsUpdateRequest(SettingsResponse):
pass
def serialize_runtime_settings(runtime_settings: RuntimeSettings) -> dict[str, str]:
return {
"alert_webhook_url": runtime_settings.alert_webhook_url or "",
"alert_threshold_count": str(runtime_settings.alert_threshold_count),
"alert_threshold_seconds": str(runtime_settings.alert_threshold_seconds),
"archive_days": str(runtime_settings.archive_days),
"failsafe_mode": runtime_settings.failsafe_mode,
}
@router.get("", response_model=SettingsResponse)
async def get_runtime_config(
runtime_settings: RuntimeSettings = Depends(get_runtime_settings),
) -> SettingsResponse:
return SettingsResponse(**runtime_settings.model_dump())
@router.put("", response_model=SettingsResponse)
async def update_runtime_config(
payload: SettingsUpdateRequest,
request: Request,
settings: Settings = Depends(get_settings),
redis: Redis | None = Depends(get_redis),
):
if redis is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Settings persistence is unavailable because Redis is offline.",
)
updated = RuntimeSettings(**payload.model_dump())
try:
await redis.hset(RUNTIME_SETTINGS_REDIS_KEY, mapping=serialize_runtime_settings(updated))
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to persist runtime settings.",
) from exc
async with request.app.state.runtime_settings_lock:
request.app.state.runtime_settings = updated
logger.info(
"Runtime settings updated.",
extra={"client_ip": extract_client_ip(request, settings)},
)
return SettingsResponse(**updated.model_dump())