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:
Your Name
2026-05-23 18:15:29 +08:00
parent 5fcc1e22a6
commit da43960f38
5 changed files with 295 additions and 53 deletions
+106 -16
View File
@@ -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 ──