feat: subscription URL stored in settings, auto-refresh allowlist every 2h
config.py: - LOGIN_SUBSCRIPTION_URL: stored in DB, configurable from UI - LOGIN_MANUAL_IPS: manually added IPs (preserved across auto-refresh) background/ip_allowlist_refresh.py (new): - ip_allowlist_refresh_loop(): primary-worker task, every 2h - _do_refresh(): fetch subscription, parse IPs, merge with manual IPs, write combined to LOGIN_ALLOWED_IPS + DB setting; runs once at startup (5s delay) - get_last_refresh_time(): returns last successful refresh timestamp main.py: - Register ip_allowlist_refresh_loop as background task settings.py: - GET /ip-allowlist: now returns subscription_url, manual_ips, last_refresh - POST /ip-allowlist: saves manual_ips + subscription_url, triggers refresh - POST /ip-allowlist/manual: append to manual list only, trigger rebuild - DELETE /ip-allowlist/ip: remove single manual IP, trigger rebuild settings.html: - Subscription URL field with 保存 + 🔄 instant-refresh button - Last refresh timestamp display - Status badge shows '订阅自动刷新' when URL configured - IP badges: blue=manual (deletable), grey=from subscription (auto) - 预览 button to inspect subscription IPs without saving Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+106
-16
@@ -339,24 +339,41 @@ async def parse_subscription(
|
||||
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
|
||||
|
||||
|
||||
@router.post("/ip-allowlist", response_model=dict)
|
||||
async def set_ip_allowlist(
|
||||
payload: IpAllowlistRequest,
|
||||
payload: IpAllowlistSaveRequest,
|
||||
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)
|
||||
"""Save manual IPs and/or subscription URL. Triggers immediate re-fetch if URL changed."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
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
|
||||
# Save 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
|
||||
|
||||
# Save subscription URL if provided
|
||||
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, just use manual IPs as the allowlist
|
||||
if not _settings.LOGIN_SUBSCRIPTION_URL:
|
||||
await repo.set("login_allowed_ips", manual_val)
|
||||
_settings.LOGIN_ALLOWED_IPS = manual_val
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
@@ -364,11 +381,72 @@ async def set_ip_allowlist(
|
||||
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 ""),
|
||||
detail=f"手动IP {len(manual_cleaned)} 条; 订阅URL {'已更新' if payload.subscription_url is not None else '未变'}",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {"success": True, "count": len(cleaned), "ips": cleaned}
|
||||
# Trigger immediate refresh if subscription URL is set
|
||||
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),
|
||||
"subscription_url": _settings.LOGIN_SUBSCRIPTION_URL or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ip-allowlist/manual", response_model=dict)
|
||||
async def add_manual_ips(
|
||||
payload: IpAllowlistRequest,
|
||||
request: Request,
|
||||
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_raw = (_settings.LOGIN_MANUAL_IPS or "")
|
||||
existing = [ip.strip() for ip in existing_raw.split(",") if ip.strip()]
|
||||
new_ips = [ip.strip() for ip in payload.ips if ip.strip()]
|
||||
merged = list(dict.fromkeys(existing + new_ips)) # dedup, preserve order
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
val = ",".join(merged)
|
||||
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 a single 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)
|
||||
@@ -376,11 +454,23 @@ 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)}
|
||||
"""Return current IP allowlist state including subscription URL and last refresh time."""
|
||||
from server.config import settings as _settings
|
||||
from server.background.ip_allowlist_refresh import get_last_refresh_time
|
||||
|
||||
combined_raw = _settings.LOGIN_ALLOWED_IPS or ""
|
||||
manual_raw = _settings.LOGIN_MANUAL_IPS or ""
|
||||
combined = [ip.strip() for ip in combined_raw.split(",") if ip.strip()]
|
||||
manual = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
|
||||
|
||||
return {
|
||||
"ips": combined,
|
||||
"manual_ips": manual,
|
||||
"count": len(combined),
|
||||
"enabled": bool(combined),
|
||||
"subscription_url": _settings.LOGIN_SUBSCRIPTION_URL or "",
|
||||
"last_refresh": get_last_refresh_time(),
|
||||
}
|
||||
|
||||
|
||||
# ── Audit Logs ──
|
||||
|
||||
Reference in New Issue
Block a user