97be1fd214
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1101 lines
38 KiB
Python
1101 lines
38 KiB
Python
"""Nexus — Settings & Schedules API Routes
|
||
Presentation layer — system settings + push schedules + password presets + audit logs.
|
||
|
||
Security: All write operations require JWT authentication.
|
||
Immutable settings (secret_key, api_key, encryption_key, database_url) cannot be modified via API.
|
||
Sensitive values are masked in GET responses.
|
||
"""
|
||
|
||
from typing import Optional
|
||
import bcrypt
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
from server.api.dependencies import get_db
|
||
from server.api.auth_jwt import get_current_admin
|
||
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, PresetUpdate, SettingUpdatePayload, ApiKeyRevealRequest, SshKeyPresetCreate, SshKeyPresetUpdate
|
||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
|
||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, SshKeyPreset, AuditLog, Admin
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
# Settings that cannot be modified via API (encryption consistency)
|
||
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
|
||
|
||
# Settings whose values are masked in GET responses
|
||
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key"}
|
||
|
||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||
|
||
|
||
# ── System Settings ──
|
||
|
||
@router.get("/", response_model=list)
|
||
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||
"""List all system settings (sensitive values masked)"""
|
||
repo = SettingRepositoryImpl(db)
|
||
settings = await repo.get_all()
|
||
result = []
|
||
for s in settings:
|
||
val = s.value
|
||
if s.key in SENSITIVE_KEYS and val:
|
||
val = val[:8] + "..." if len(val) > 8 else "***"
|
||
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
|
||
return result
|
||
|
||
|
||
@router.get("/{key}", response_model=dict)
|
||
async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||
"""Get a single setting by key (sensitive values masked)"""
|
||
repo = SettingRepositoryImpl(db)
|
||
value = await repo.get(key)
|
||
if value is None:
|
||
raise HTTPException(status_code=404, detail="Setting not found")
|
||
if key in SENSITIVE_KEYS and value:
|
||
value = value[:8] + "..." if len(value) > 8 else "***"
|
||
return {"key": key, "value": value}
|
||
|
||
|
||
@router.post("/api-key/reveal", response_model=dict)
|
||
async def reveal_api_key(
|
||
payload: ApiKeyRevealRequest,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Reveal global API_KEY after re-entering account password."""
|
||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||
|
||
admin_repo = AdminRepositoryImpl(db)
|
||
current = await admin_repo.get_by_id(admin.id)
|
||
if not current or not bcrypt.checkpw(
|
||
payload.current_password.encode(),
|
||
current.password_hash.encode(),
|
||
):
|
||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||
|
||
repo = SettingRepositoryImpl(db)
|
||
value = await repo.get("api_key")
|
||
if value is None:
|
||
raise HTTPException(status_code=404, detail="API_KEY not found")
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="reveal_api_key",
|
||
target_type="setting",
|
||
detail="查看全局 API Key",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
return {"key": "api_key", "value": value}
|
||
|
||
|
||
@router.put("/{key}", response_model=dict)
|
||
async def set_setting(
|
||
key: str,
|
||
payload: SettingUpdatePayload,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Set a system setting value (immutable keys are rejected)"""
|
||
if key in IMMUTABLE_KEYS:
|
||
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
|
||
|
||
repo = SettingRepositoryImpl(db)
|
||
setting = await repo.set(key, str(payload.value))
|
||
|
||
# Audit log
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="update_setting",
|
||
target_type="setting",
|
||
detail=f"键={key}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"key": setting.key, "value": setting.value}
|
||
|
||
|
||
# ── Push Schedules ──
|
||
|
||
schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
|
||
|
||
|
||
@schedule_router.get("/", response_model=list)
|
||
async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||
"""List all push schedules"""
|
||
repo = PushScheduleRepositoryImpl(db)
|
||
schedules = await repo.get_all()
|
||
return [_schedule_to_dict(s) for s in schedules]
|
||
|
||
|
||
@schedule_router.post("/", response_model=dict, status_code=201)
|
||
async def create_schedule(
|
||
payload: ScheduleCreate,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Create a new push schedule"""
|
||
repo = PushScheduleRepositoryImpl(db)
|
||
schedule = PushSchedule(**payload.model_dump())
|
||
created = await repo.create(schedule)
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="create_schedule",
|
||
target_type="schedule",
|
||
target_id=created.id,
|
||
detail=f"名称={created.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return _schedule_to_dict(created)
|
||
|
||
|
||
@schedule_router.put("/{id}", response_model=dict)
|
||
async def update_schedule(
|
||
id: int,
|
||
payload: ScheduleUpdate,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Update a push schedule"""
|
||
repo = PushScheduleRepositoryImpl(db)
|
||
schedule = await repo.get_by_id(id)
|
||
if not schedule:
|
||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
# Explicit allowlist to prevent mass-assignment to sensitive columns
|
||
ALLOWED_FIELDS = {
|
||
"name", "source_path", "server_ids", "run_mode", "cron_expr",
|
||
"fire_at", "sync_mode", "enabled", "schedule_type",
|
||
"script_id", "script_content", "exec_timeout", "long_task",
|
||
}
|
||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||
if key in ALLOWED_FIELDS:
|
||
setattr(schedule, key, value)
|
||
updated = await repo.update(schedule)
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="update_schedule",
|
||
target_type="schedule",
|
||
target_id=id,
|
||
detail=f"名称={updated.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return _schedule_to_dict(updated)
|
||
|
||
|
||
@schedule_router.delete("/{id}", status_code=204)
|
||
async def delete_schedule(
|
||
id: int,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Delete a push schedule"""
|
||
repo = PushScheduleRepositoryImpl(db)
|
||
result = await repo.delete(id)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="delete_schedule",
|
||
target_type="schedule",
|
||
target_id=id,
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
|
||
# ── Password Presets ──
|
||
|
||
preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
|
||
|
||
|
||
@preset_router.get("/", response_model=list)
|
||
async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||
"""List all password presets"""
|
||
repo = PasswordPresetRepositoryImpl(db)
|
||
presets = await repo.get_all()
|
||
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
|
||
|
||
|
||
@preset_router.post("/", response_model=dict, status_code=201)
|
||
async def create_preset(
|
||
payload: PresetCreate,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Create a password preset (password will be encrypted)"""
|
||
from server.infrastructure.database.crypto import encrypt_value
|
||
repo = PasswordPresetRepositoryImpl(db)
|
||
encrypted = encrypt_value(payload.encrypted_pw)
|
||
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
|
||
created = await repo.create(preset)
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="create_preset",
|
||
target_type="preset",
|
||
target_id=created.id,
|
||
detail=f"名称={created.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"id": created.id, "name": created.name}
|
||
|
||
|
||
@preset_router.put("/{id}", response_model=dict)
|
||
async def update_preset(
|
||
id: int,
|
||
payload: PresetUpdate,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Update a password preset (name and/or password — password encrypted)"""
|
||
from server.infrastructure.database.crypto import encrypt_value
|
||
|
||
repo = PasswordPresetRepositoryImpl(db)
|
||
preset = await repo.get_by_id(id)
|
||
if not preset:
|
||
raise HTTPException(status_code=404, detail="Preset not found")
|
||
|
||
updates = payload.model_dump(exclude_unset=True)
|
||
new_pw = updates.pop("encrypted_pw", None)
|
||
if updates.get("name"):
|
||
preset.name = updates["name"]
|
||
if new_pw:
|
||
preset.encrypted_pw = encrypt_value(new_pw)
|
||
|
||
await repo.update(preset)
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="update_preset",
|
||
target_type="preset",
|
||
target_id=id,
|
||
detail=f"名称={preset.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"id": preset.id, "name": preset.name}
|
||
|
||
|
||
@preset_router.post("/{id}/reveal", response_model=dict)
|
||
async def reveal_preset(
|
||
id: int,
|
||
payload: ApiKeyRevealRequest,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Reveal a password preset's decrypted value (re-auth + audit-logged)."""
|
||
from server.infrastructure.database.crypto import decrypt_value
|
||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||
|
||
# Re-authenticate: require current password (same as reveal_api_key)
|
||
admin_repo = AdminRepositoryImpl(db)
|
||
current = await admin_repo.get_by_id(admin.id)
|
||
if not current or not bcrypt.checkpw(
|
||
payload.current_password.encode(),
|
||
current.password_hash.encode(),
|
||
):
|
||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||
|
||
repo = PasswordPresetRepositoryImpl(db)
|
||
preset = await repo.get_by_id(id)
|
||
if not preset:
|
||
raise HTTPException(status_code=404, detail="Preset not found")
|
||
|
||
try:
|
||
plaintext = decrypt_value(preset.encrypted_pw)
|
||
except ValueError:
|
||
raise HTTPException(status_code=500, detail="解密失败")
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username, action="reveal_preset",
|
||
target_type="preset", target_id=id,
|
||
detail=f"名称={preset.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"id": preset.id, "name": preset.name, "password": plaintext}
|
||
|
||
|
||
@preset_router.delete("/{id}", status_code=204)
|
||
async def delete_preset(
|
||
id: int,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Delete a password preset"""
|
||
repo = PasswordPresetRepositoryImpl(db)
|
||
result = await repo.delete(id)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Preset not found")
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="delete_preset",
|
||
target_type="preset",
|
||
target_id=id,
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
|
||
# ── SSH Key Presets ──
|
||
|
||
ssh_key_preset_router = APIRouter(prefix="/api/ssh-key-presets", tags=["ssh-key-presets"])
|
||
|
||
|
||
@ssh_key_preset_router.get("/", response_model=list)
|
||
async def list_ssh_key_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||
"""List all SSH key presets"""
|
||
repo = SshKeyPresetRepositoryImpl(db)
|
||
presets = await repo.get_all()
|
||
return [{"id": p.id, "name": p.name, "public_key": p.public_key or "", "created_at": str(p.created_at)} for p in presets]
|
||
|
||
|
||
@ssh_key_preset_router.post("/", response_model=dict, status_code=201)
|
||
async def create_ssh_key_preset(
|
||
payload: SshKeyPresetCreate,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Create an SSH key preset (private key will be encrypted)"""
|
||
from server.infrastructure.database.crypto import encrypt_value
|
||
repo = SshKeyPresetRepositoryImpl(db)
|
||
encrypted_private = encrypt_value(payload.private_key)
|
||
preset = SshKeyPreset(name=payload.name, encrypted_private_key=encrypted_private, public_key=payload.public_key)
|
||
created = await repo.create(preset)
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="create_ssh_key_preset",
|
||
target_type="ssh_key_preset",
|
||
target_id=created.id,
|
||
detail=f"名称={created.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"id": created.id, "name": created.name}
|
||
|
||
|
||
@ssh_key_preset_router.put("/{id}", response_model=dict)
|
||
async def update_ssh_key_preset(
|
||
id: int,
|
||
payload: SshKeyPresetUpdate,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Update an SSH key preset (name and/or private key — private key encrypted)"""
|
||
from server.infrastructure.database.crypto import encrypt_value
|
||
|
||
repo = SshKeyPresetRepositoryImpl(db)
|
||
preset = await repo.get_by_id(id)
|
||
if not preset:
|
||
raise HTTPException(status_code=404, detail="SSH key preset not found")
|
||
|
||
updates = payload.model_dump(exclude_unset=True)
|
||
new_private_key = updates.pop("private_key", None)
|
||
if updates.get("name"):
|
||
preset.name = updates["name"]
|
||
if new_private_key:
|
||
preset.encrypted_private_key = encrypt_value(new_private_key)
|
||
if "public_key" in updates:
|
||
preset.public_key = updates["public_key"]
|
||
|
||
await repo.update(preset)
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="update_ssh_key_preset",
|
||
target_type="ssh_key_preset",
|
||
target_id=id,
|
||
detail=f"名称={preset.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"id": preset.id, "name": preset.name}
|
||
|
||
|
||
@ssh_key_preset_router.post("/{id}/reveal", response_model=dict)
|
||
async def reveal_ssh_key_preset(
|
||
id: int,
|
||
payload: ApiKeyRevealRequest,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Reveal an SSH key preset's decrypted private key (re-auth + audit-logged)."""
|
||
from server.infrastructure.database.crypto import decrypt_value
|
||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||
|
||
# Re-authenticate: require current password
|
||
admin_repo = AdminRepositoryImpl(db)
|
||
current = await admin_repo.get_by_id(admin.id)
|
||
if not current or not bcrypt.checkpw(
|
||
payload.current_password.encode(),
|
||
current.password_hash.encode(),
|
||
):
|
||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||
|
||
repo = SshKeyPresetRepositoryImpl(db)
|
||
preset = await repo.get_by_id(id)
|
||
if not preset:
|
||
raise HTTPException(status_code=404, detail="SSH key preset not found")
|
||
|
||
try:
|
||
plaintext = decrypt_value(preset.encrypted_private_key)
|
||
except ValueError:
|
||
raise HTTPException(status_code=500, detail="解密失败")
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username, action="reveal_ssh_key_preset",
|
||
target_type="ssh_key_preset", target_id=id,
|
||
detail=f"名称={preset.name}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return {"id": preset.id, "name": preset.name, "private_key": plaintext, "public_key": preset.public_key or ""}
|
||
|
||
|
||
@ssh_key_preset_router.delete("/{id}", status_code=204)
|
||
async def delete_ssh_key_preset(
|
||
id: int,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Delete an SSH key preset"""
|
||
repo = SshKeyPresetRepositoryImpl(db)
|
||
result = await repo.delete(id)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="SSH key preset not found")
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="delete_ssh_key_preset",
|
||
target_type="ssh_key_preset",
|
||
target_id=id,
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
|
||
# ── Alert History ──
|
||
|
||
alert_history_router = APIRouter(prefix="/api/alert-history", tags=["alerts"])
|
||
|
||
|
||
@alert_history_router.get("/", response_model=dict)
|
||
async def list_alert_history(
|
||
server_id: Optional[int] = None,
|
||
alert_type: Optional[str] = None,
|
||
is_recovery: Optional[bool] = None,
|
||
date_from: Optional[str] = None,
|
||
date_to: Optional[str] = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Paginated alert/recovery history with filters."""
|
||
from sqlalchemy import select, func
|
||
from server.domain.models import AlertLog, Server
|
||
from datetime import datetime, timezone
|
||
|
||
filters = []
|
||
if server_id:
|
||
filters.append(AlertLog.server_id == server_id)
|
||
if alert_type:
|
||
filters.append(AlertLog.alert_type == alert_type)
|
||
if is_recovery is not None:
|
||
filters.append(AlertLog.is_recovery == is_recovery)
|
||
if date_from:
|
||
try:
|
||
dt = datetime.fromisoformat(date_from)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
filters.append(AlertLog.created_at >= dt.replace(tzinfo=None))
|
||
except ValueError:
|
||
pass
|
||
if date_to:
|
||
try:
|
||
dt = datetime.fromisoformat(date_to)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
filters.append(AlertLog.created_at <= dt.replace(tzinfo=None))
|
||
except ValueError:
|
||
pass
|
||
|
||
count_q = select(func.count(AlertLog.id))
|
||
data_q = (
|
||
select(AlertLog, Server.name.label("srv_name"))
|
||
.join(Server, AlertLog.server_id == Server.id, isouter=True)
|
||
.order_by(AlertLog.created_at.desc())
|
||
.offset(offset).limit(limit)
|
||
)
|
||
if filters:
|
||
count_q = count_q.where(*filters)
|
||
data_q = data_q.where(*filters)
|
||
|
||
total = int((await db.execute(count_q)).scalar() or 0)
|
||
rows = (await db.execute(data_q)).all()
|
||
|
||
def _to_dict(log, srv_name):
|
||
return {
|
||
"id": log.id,
|
||
"server_id": log.server_id,
|
||
"server_name": log.server_name or srv_name or f"#{log.server_id}",
|
||
"alert_type": log.alert_type,
|
||
"value": log.value,
|
||
"is_recovery": log.is_recovery,
|
||
"created_at": str(log.created_at) if log.created_at else None,
|
||
}
|
||
|
||
return {
|
||
"items": [_to_dict(log, sn) for log, sn in rows],
|
||
"total": total, "limit": limit, "offset": offset,
|
||
}
|
||
|
||
|
||
@alert_history_router.get("/stats", response_model=dict)
|
||
async def alert_stats(
|
||
days: int = 7,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Top alerting servers + daily counts for the last N days."""
|
||
from sqlalchemy import select, func, text
|
||
from server.domain.models import AlertLog
|
||
|
||
# Top 5 servers by alert count
|
||
top_q = (
|
||
select(AlertLog.server_id, AlertLog.server_name, func.count(AlertLog.id).label("cnt"))
|
||
.where(AlertLog.is_recovery == False)
|
||
.group_by(AlertLog.server_id, AlertLog.server_name)
|
||
.order_by(func.count(AlertLog.id).desc())
|
||
.limit(5)
|
||
)
|
||
top_rows = (await db.execute(top_q)).all()
|
||
|
||
# Top 5 alert types by count
|
||
type_q = (
|
||
select(AlertLog.alert_type, func.count(AlertLog.id).label("cnt"))
|
||
.where(AlertLog.is_recovery == False)
|
||
.group_by(AlertLog.alert_type)
|
||
.order_by(func.count(AlertLog.id).desc())
|
||
.limit(5)
|
||
)
|
||
type_rows = (await db.execute(type_q)).all()
|
||
|
||
# Daily counts (parameterized to avoid f-string SQL)
|
||
days_val = int(days)
|
||
daily_q = text(
|
||
"SELECT DATE(created_at) as day, "
|
||
"SUM(is_recovery=0) as alerts, SUM(is_recovery=1) as recoveries "
|
||
"FROM alert_logs "
|
||
"WHERE created_at >= DATE_SUB(NOW(), INTERVAL :days DAY) "
|
||
"GROUP BY DATE(created_at) ORDER BY day"
|
||
).bindparams(days=days_val)
|
||
daily_rows = (await db.execute(daily_q)).all()
|
||
|
||
return {
|
||
"top_types": [{"alert_type": r.alert_type, "count": r.cnt} for r in type_rows],
|
||
"top_servers": [{"server_id": r.server_id, "server_name": r.server_name or f"#{r.server_id}", "count": r.cnt} for r in top_rows],
|
||
"daily": [{"day": str(r.day), "alerts": int(r.alerts), "recoveries": int(r.recoveries)} for r in daily_rows],
|
||
}
|
||
|
||
|
||
# ── Telegram Test + Chat ID Detection ──
|
||
|
||
@router.post("/telegram/test", response_model=dict)
|
||
async def telegram_test_send(
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Send a test Telegram message to verify Bot Token + Chat ID configuration."""
|
||
from server.infrastructure.telegram import send_telegram
|
||
from server.config import settings as _settings
|
||
|
||
if not _settings.TELEGRAM_BOT_TOKEN:
|
||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||
if not _settings.TELEGRAM_CHAT_ID:
|
||
raise HTTPException(status_code=400, detail="TELEGRAM_CHAT_ID 未配置,请先检测并填写")
|
||
|
||
from datetime import datetime, timezone
|
||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||
ok = await send_telegram(
|
||
f"✅ <b>Nexus 测试消息</b>\n"
|
||
f"Bot 配置正确,告警通知已就绪\n"
|
||
f"时间: {now}"
|
||
)
|
||
if ok:
|
||
return {"success": True}
|
||
raise HTTPException(status_code=502, detail="Telegram API 返回错误,请检查 Bot Token 和 Chat ID")
|
||
|
||
|
||
@router.get("/telegram/chats", response_model=dict)
|
||
async def telegram_get_chats(
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Call getUpdates to detect recent chats and extract unique chat_ids.
|
||
|
||
User must send at least one message to the Bot before calling this.
|
||
Returns up to 5 distinct chats from recent messages.
|
||
"""
|
||
import httpx
|
||
from server.config import settings as _settings
|
||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||
|
||
token = _settings.TELEGRAM_BOT_TOKEN
|
||
if not token:
|
||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
resp = await client.get(
|
||
f"{TELEGRAM_API_BASE}/bot{token}/getUpdates",
|
||
params={"limit": 50, "allowed_updates": ["message", "channel_post"]},
|
||
)
|
||
if resp.status_code != 200:
|
||
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
||
data = resp.json()
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"请求失败: {e}")
|
||
|
||
# Extract unique chats
|
||
seen: set[int] = set()
|
||
chats: list[dict] = []
|
||
for update in data.get("result", []):
|
||
msg = update.get("message") or update.get("channel_post") or {}
|
||
chat = msg.get("chat", {})
|
||
cid = chat.get("id")
|
||
if cid and cid not in seen:
|
||
seen.add(cid)
|
||
chats.append({
|
||
"id": cid,
|
||
"type": chat.get("type", ""),
|
||
"title": chat.get("title") or chat.get("first_name") or "",
|
||
"username": chat.get("username") or "",
|
||
})
|
||
if len(chats) >= 5:
|
||
break
|
||
|
||
return {"chats": chats, "count": len(chats)}
|
||
|
||
|
||
# ── IP Allowlist ──
|
||
|
||
class IpAllowlistRequest(BaseModel):
|
||
ips: list[str] = Field(..., description="IP or hostname list to set as allowlist")
|
||
|
||
|
||
class SubscriptionParseRequest(BaseModel):
|
||
url: str = Field(..., min_length=1, description="Proxy subscription URL to fetch and parse")
|
||
|
||
|
||
@router.post("/ip-allowlist/parse-subscription", response_model=dict)
|
||
async def parse_subscription(
|
||
payload: SubscriptionParseRequest,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Fetch a proxy subscription URL, decode it, and extract node host/IP list.
|
||
|
||
Supports SS, VMess, VLESS, Trojan, Hysteria2 formats.
|
||
Returns the parsed host list for the user to review before saving.
|
||
"""
|
||
import httpx
|
||
from server.infrastructure.subscription_parser import parse_subscription_content
|
||
|
||
url = payload.url.strip()
|
||
if not (url.startswith("http://") or url.startswith("https://")):
|
||
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(url, follow_redirects=True)
|
||
if resp.status_code != 200:
|
||
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
|
||
raw = resp.text
|
||
except httpx.TimeoutException:
|
||
raise HTTPException(status_code=400, detail="请求订阅链接超时(15s)")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"获取订阅失败: {e}")
|
||
|
||
hosts = parse_subscription_content(raw)
|
||
if not hosts:
|
||
raise HTTPException(status_code=400, detail="未能从订阅内容中解析出任何节点 IP / 域名")
|
||
|
||
# Audit
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
from server.domain.models import AuditLog
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username, action="parse_subscription",
|
||
target_type="setting", detail=f"解析订阅: {url[:80]} → {len(hosts)} 个节点",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
return {"hosts": hosts, "count": len(hosts)}
|
||
|
||
|
||
class IpAllowlistSaveRequest(BaseModel):
|
||
manual_ips: list[str] = Field(default_factory=list)
|
||
subscription_url: Optional[str] = None # None = don't change; "" = clear
|
||
enabled: Optional[bool] = None # None = don't change
|
||
|
||
|
||
@router.post("/ip-allowlist", response_model=dict)
|
||
async def set_ip_allowlist(
|
||
payload: IpAllowlistSaveRequest,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Save manual IPs, subscription URL, and/or enabled toggle."""
|
||
from server.config import settings as _settings
|
||
|
||
repo = SettingRepositoryImpl(db)
|
||
|
||
# Toggle enabled/disabled
|
||
if payload.enabled is not None:
|
||
val = "true" if payload.enabled else "false"
|
||
await repo.set("login_allowlist_enabled", val)
|
||
_settings.LOGIN_ALLOWLIST_ENABLED = val
|
||
|
||
# Manual IPs
|
||
manual_cleaned = [ip.strip() for ip in payload.manual_ips if ip.strip()]
|
||
manual_val = ",".join(manual_cleaned)
|
||
await repo.set("login_manual_ips", manual_val)
|
||
_settings.LOGIN_MANUAL_IPS = manual_val
|
||
|
||
# Subscription URL
|
||
sub_url_changed = False
|
||
if payload.subscription_url is not None:
|
||
sub_url = payload.subscription_url.strip()
|
||
await repo.set("login_subscription_url", sub_url)
|
||
_settings.LOGIN_SUBSCRIPTION_URL = sub_url
|
||
sub_url_changed = bool(sub_url)
|
||
|
||
# If no subscription URL, rebuild combined from manual only
|
||
if not _settings.LOGIN_SUBSCRIPTION_URL:
|
||
await repo.set("login_allowed_ips", manual_val)
|
||
await repo.set("login_subscription_ips", "")
|
||
_settings.LOGIN_ALLOWED_IPS = manual_val
|
||
_settings.LOGIN_SUBSCRIPTION_IPS = ""
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
from server.domain.models import AuditLog
|
||
ip_address = request.client.host if request.client else ""
|
||
enabled_str = "" if payload.enabled is None else f"; 白名单开关={'开' if payload.enabled else '关'}"
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username, action="update_ip_allowlist",
|
||
target_type="setting",
|
||
detail=f"手动IP {len(manual_cleaned)} 条{enabled_str}",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
# Trigger refresh if subscription URL is present
|
||
if sub_url_changed or _settings.LOGIN_SUBSCRIPTION_URL:
|
||
from server.background.ip_allowlist_refresh import _do_refresh
|
||
import asyncio
|
||
asyncio.create_task(_do_refresh())
|
||
|
||
return {"success": True, "manual_count": len(manual_cleaned)}
|
||
|
||
|
||
@router.post("/ip-allowlist/toggle", response_model=dict)
|
||
async def toggle_ip_allowlist(
|
||
request: Request,
|
||
enabled: bool,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Quick toggle for allowlist enforcement (no IP list changes)."""
|
||
from server.config import settings as _settings
|
||
|
||
val = "true" if enabled else "false"
|
||
repo = SettingRepositoryImpl(db)
|
||
await repo.set("login_allowlist_enabled", val)
|
||
_settings.LOGIN_ALLOWLIST_ENABLED = val
|
||
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
from server.domain.models import AuditLog
|
||
ip_address = request.client.host if request.client else ""
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username, action="toggle_ip_allowlist",
|
||
target_type="setting",
|
||
detail=f"登录IP白名单: {'启用' if enabled else '关闭'}",
|
||
ip_address=ip_address,
|
||
))
|
||
return {"success": True, "enabled": enabled}
|
||
|
||
|
||
@router.post("/ip-allowlist/manual", response_model=dict)
|
||
async def add_manual_ips(
|
||
payload: IpAllowlistRequest,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Append IPs to the manual list and trigger allowlist rebuild."""
|
||
from server.config import settings as _settings
|
||
|
||
existing = [ip.strip() for ip in (_settings.LOGIN_MANUAL_IPS or "").split(",") if ip.strip()]
|
||
new_ips = [ip.strip() for ip in payload.ips if ip.strip()]
|
||
merged = list(dict.fromkeys(existing + new_ips))
|
||
val = ",".join(merged)
|
||
repo = SettingRepositoryImpl(db)
|
||
await repo.set("login_manual_ips", val)
|
||
_settings.LOGIN_MANUAL_IPS = val
|
||
|
||
from server.background.ip_allowlist_refresh import _do_refresh
|
||
import asyncio
|
||
asyncio.create_task(_do_refresh())
|
||
return {"success": True, "manual_ips": merged}
|
||
|
||
|
||
@router.delete("/ip-allowlist/ip", response_model=dict)
|
||
async def remove_allowlist_ip(
|
||
ip: str,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Remove one IP from the manual list and rebuild."""
|
||
from server.config import settings as _settings
|
||
|
||
existing = [i.strip() for i in (_settings.LOGIN_MANUAL_IPS or "").split(",") if i.strip()]
|
||
remaining = [i for i in existing if i != ip.strip()]
|
||
val = ",".join(remaining)
|
||
repo = SettingRepositoryImpl(db)
|
||
await repo.set("login_manual_ips", val)
|
||
_settings.LOGIN_MANUAL_IPS = val
|
||
|
||
from server.background.ip_allowlist_refresh import _do_refresh
|
||
import asyncio
|
||
asyncio.create_task(_do_refresh())
|
||
return {"success": True, "removed": ip, "remaining": remaining}
|
||
|
||
|
||
@router.get("/ip-allowlist", response_model=dict)
|
||
async def get_ip_allowlist(
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Return full allowlist state."""
|
||
from server.config import settings as _settings
|
||
from server.background.ip_allowlist_refresh import get_last_refresh_time, get_subscription_count
|
||
|
||
allowlist_on = (_settings.LOGIN_ALLOWLIST_ENABLED or "false").lower() in ("true","1","yes","on")
|
||
sub_ips_raw = _settings.LOGIN_SUBSCRIPTION_IPS or ""
|
||
manual_raw = _settings.LOGIN_MANUAL_IPS or ""
|
||
sub_ips = [ip.strip() for ip in sub_ips_raw.split(",") if ip.strip()]
|
||
manual_ips = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
|
||
|
||
return {
|
||
"enabled": allowlist_on,
|
||
"subscription_url": _settings.LOGIN_SUBSCRIPTION_URL or "",
|
||
"subscription_ips": sub_ips,
|
||
"manual_ips": manual_ips,
|
||
"subscription_count": len(sub_ips),
|
||
"manual_count": len(manual_ips),
|
||
"total_count": len(sub_ips) + len(set(manual_ips) - set(sub_ips)),
|
||
"last_refresh": get_last_refresh_time(),
|
||
}
|
||
|
||
|
||
# ── Audit Logs ──
|
||
|
||
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||
|
||
|
||
@audit_router.get("/", response_model=dict)
|
||
async def list_audit_logs(
|
||
action: Optional[str] = None,
|
||
admin_username: Optional[str] = None,
|
||
date_from: Optional[str] = None, # ISO date: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
|
||
date_to: Optional[str] = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""List audit logs with pagination, action filter, admin filter, and date range."""
|
||
repo = AuditLogRepositoryImpl(db)
|
||
logs, total = await repo.query(
|
||
action=action,
|
||
admin_username=admin_username,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return {
|
||
"total": total,
|
||
"limit": limit,
|
||
"offset": offset,
|
||
"items": [_audit_to_dict(log) for log in logs],
|
||
}
|
||
|
||
|
||
# ── Retry Jobs ──
|
||
|
||
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
|
||
|
||
|
||
@retry_router.get("/", response_model=list)
|
||
async def list_retry_jobs(
|
||
limit: int = 100,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""List all retry jobs (not just pending)"""
|
||
repo = PushRetryJobRepositoryImpl(db)
|
||
jobs = await repo.get_all(limit)
|
||
return [_retry_to_dict(j) for j in jobs]
|
||
|
||
|
||
@retry_router.post("/{id}/retry", response_model=dict)
|
||
async def retry_job(
|
||
id: int,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Manually retry a failed/pending job"""
|
||
repo = PushRetryJobRepositoryImpl(db)
|
||
job = await repo.get_by_id(id)
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="Retry job not found")
|
||
|
||
# Reset the job to pending state for immediate retry
|
||
job = await repo.update_status(
|
||
id, "pending",
|
||
retry_count=0,
|
||
next_retry_at=None,
|
||
last_error=None,
|
||
)
|
||
|
||
# Audit log
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="retry_job",
|
||
target_type="retry",
|
||
target_id=id,
|
||
detail=f"手动重试任务 #{id}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
return _retry_to_dict(job)
|
||
|
||
|
||
@retry_router.delete("/{id}", status_code=204)
|
||
async def delete_retry_job(
|
||
id: int,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Delete a retry job"""
|
||
repo = PushRetryJobRepositoryImpl(db)
|
||
job = await repo.get_by_id(id)
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="Retry job not found")
|
||
|
||
# Audit log
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="delete_retry",
|
||
target_type="retry",
|
||
target_id=id,
|
||
detail=f"删除重试任务 #{id}",
|
||
ip_address=request.client.host if request.client else "",
|
||
))
|
||
|
||
await repo.delete(id)
|
||
|
||
|
||
# ── Helper functions ──
|
||
|
||
def _schedule_to_dict(schedule: PushSchedule) -> dict:
|
||
return {
|
||
"id": schedule.id,
|
||
"name": schedule.name,
|
||
"source_path": schedule.source_path,
|
||
"server_ids": schedule.server_ids,
|
||
"run_mode": getattr(schedule, "run_mode", None) or "once",
|
||
"cron_expr": schedule.cron_expr,
|
||
"fire_at": str(schedule.fire_at) if getattr(schedule, "fire_at", None) else None,
|
||
"sync_mode": schedule.sync_mode or "incremental",
|
||
"enabled": schedule.enabled,
|
||
"schedule_type": getattr(schedule, "schedule_type", None) or "push",
|
||
"script_id": getattr(schedule, "script_id", None),
|
||
"script_content": getattr(schedule, "script_content", None),
|
||
"exec_timeout": getattr(schedule, "exec_timeout", 60),
|
||
"long_task": getattr(schedule, "long_task", False),
|
||
"last_run_at": str(schedule.last_run_at) if schedule.last_run_at else None,
|
||
"created_at": str(schedule.created_at) if schedule.created_at else None,
|
||
}
|
||
|
||
|
||
def _audit_to_dict(log: AuditLog) -> dict:
|
||
return {
|
||
"id": log.id,
|
||
"admin_username": log.admin_username,
|
||
"action": log.action,
|
||
"target_type": log.target_type,
|
||
"target_id": log.target_id,
|
||
"detail": log.detail,
|
||
"ip_address": log.ip_address,
|
||
"created_at": str(log.created_at) if log.created_at else None,
|
||
}
|
||
|
||
|
||
def _retry_to_dict(job: PushRetryJob) -> dict:
|
||
return {
|
||
"id": job.id,
|
||
"server_id": job.server_id,
|
||
"server_name": job.server_name,
|
||
"operator": job.operator,
|
||
"source_path": job.source_path,
|
||
"target_path": job.target_path,
|
||
"status": job.status,
|
||
"retry_count": job.retry_count,
|
||
"max_retries": job.max_retries,
|
||
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
|
||
"last_error": job.last_error,
|
||
"created_at": str(job.created_at) if job.created_at else None,
|
||
}
|