feat(core): 初始化 Key-IP Sentinel 服务与部署骨架
- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构 - 实现反向代理、首用绑定、拦截告警、归档任务和管理接口 - 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
49
app/api/auth.py
Normal file
49
app/api/auth.py
Normal 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
153
app/api/bindings.py
Normal 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
109
app/api/dashboard.py
Normal 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
107
app/api/logs.py
Normal 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
77
app/api/settings.py
Normal 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())
|
||||
Reference in New Issue
Block a user