Files
Nexus/server/api/settings.py
T
Your Name 5fcc1e22a6 feat: login IP allowlist with proxy subscription parser
server/infrastructure/subscription_parser.py (new):
- parse_subscription_content(): fetch and decode subscription, extract host/IP
- Supports SS, VMess, VLESS, Trojan, Hysteria2 formats
- Base64 decode with padding fix; IPv4/CIDR/hostname detection

server/config.py:
- LOGIN_ALLOWED_IPS setting (empty = allow all, comma-separated)

server/api/settings.py:
- POST /settings/ip-allowlist/parse-subscription: fetch URL, decode, return hosts
- POST /settings/ip-allowlist: save allowlist to settings table + reload
- GET /settings/ip-allowlist: return current list

server/application/services/auth_service.py:
- _ip_in_allowlist(): supports exact IP, CIDR (ipaddress module), hostname match
- login(): check allowlist before brute-force check; if IP blocked return
  reason='ip_blocked' with clear message; audit log 'login_ip_blocked'

web/app/settings.html:
- New '登录 IP 白名单' card with:
  - Subscription URL input + parse button (calls backend parser)
  - Parsed IPs with checkboxes (select/deselect before adding)
  - Manual IP/CIDR/hostname textarea input
  - Current allowlist with per-IP remove buttons
  - Clear all button (removes restriction)
  - Status badge: 启用 N条 / 未启用(不限制)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 18:11:23 +08:00

543 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, SettingUpdatePayload
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.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, 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"])
class ApiKeyRevealRequest(BaseModel):
current_password: str = Field(..., min_length=1, max_length=255)
# ── 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="Global API key revealed",
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={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"name={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")
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(schedule, key) and key != "id":
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"name={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"name={created.name}",
ip_address=request.client.host if request.client else "",
))
return {"id": created.id, "name": created.name}
@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 "",
))
# ── 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)}
@router.post("/ip-allowlist", response_model=dict)
async def set_ip_allowlist(
payload: IpAllowlistRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Save login IP allowlist. Empty list = allow all IPs (no restriction)."""
# Deduplicate and strip
cleaned = [ip.strip() for ip in payload.ips if ip.strip()]
value = ",".join(cleaned)
repo = SettingRepositoryImpl(db)
await repo.set("login_allowed_ips", value)
# Reload into running settings
from server.config import settings as _settings
_settings.LOGIN_ALLOWED_IPS = value
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="update_ip_allowlist",
target_type="setting",
detail=f"IP白名单更新: {len(cleaned)} 条" + (" (清空=不限制)" if not cleaned else ""),
ip_address=ip_address,
))
return {"success": True, "count": len(cleaned), "ips": cleaned}
@router.get("/ip-allowlist", response_model=dict)
async def get_ip_allowlist(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return current login IP allowlist."""
repo = SettingRepositoryImpl(db)
value = await repo.get("login_allowed_ips") or ""
ips = [ip.strip() for ip in value.split(",") if ip.strip()]
return {"ips": ips, "count": len(ips), "enabled": bool(ips)}
# ── 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"Manual retry triggered for job #{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"Retry job #{id} deleted",
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,
"cron_expr": schedule.cron_expr,
"sync_mode": schedule.sync_mode or "incremental",
"enabled": schedule.enabled,
"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,
}