"""Background task: refresh login subscription IPs every 2 hours. Data model: login_subscription_url — URL to fetch (configurable in settings) login_subscription_ips — IPs from last fetch (completely replaced each time) login_manual_ips — manually added (never touched by auto-refresh) login_allowed_ips — combined: subscription + manual (used by auth check) login_allowlist_enabled — "true" / "false" master switch """ from __future__ import annotations import asyncio import logging from dataclasses import dataclass logger = logging.getLogger("nexus.ip_allowlist_refresh") REFRESH_INTERVAL = 7200 # 2 hours _last_refresh_at: str = "" _last_subscription_count: int = 0 @dataclass(frozen=True) class RefreshResult: ok: bool subscription_ips: tuple[str, ...] = () last_refresh: str = "" subscription_count: int = 0 error: str | None = None async def ip_allowlist_refresh_loop(): """Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist.""" logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600) # Initial refresh shortly after startup await asyncio.sleep(5) await do_refresh() while True: await asyncio.sleep(REFRESH_INTERVAL) await do_refresh() async def do_refresh() -> RefreshResult: """Fetch subscription, replace subscription IPs, rebuild combined list.""" return await _do_refresh() async def _do_refresh() -> RefreshResult: """Fetch subscription, replace subscription IPs, rebuild combined list.""" global _last_refresh_at, _last_subscription_count from server.config import settings from server.infrastructure.subscription_parser import parse_subscription_content sub_url = (settings.LOGIN_SUBSCRIPTION_URL or "").strip() if not sub_url: return RefreshResult(ok=True) logger.info("Refreshing subscription IPs from: %s", sub_url[:80]) try: import httpx async with httpx.AsyncClient(timeout=20.0) as client: resp = await client.get(sub_url, follow_redirects=True) if resp.status_code != 200: logger.warning("Subscription returned HTTP %s", resp.status_code) return RefreshResult(ok=False, error=f"订阅链接返回 HTTP {resp.status_code}") new_sub_ips = parse_subscription_content(resp.text) except Exception as e: logger.error("Subscription fetch failed: %s", e) return RefreshResult(ok=False, error=f"拉取订阅失败: {e}") # Manual IPs (always preserved) manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip() manual_ips = [ip.strip() for ip in manual_raw.split(",") if ip.strip()] # Combined list: subscription first, then manual (dedup) seen: set[str] = set() combined: list[str] = [] for ip in new_sub_ips + manual_ips: if ip not in seen: seen.add(ip) combined.append(ip) sub_val = ",".join(new_sub_ips) combined_val = ",".join(combined) try: from server.infrastructure.database.session import AsyncSessionLocal from server.infrastructure.database.setting_repo import SettingRepositoryImpl from server.infrastructure.settings_broadcast import publish_setting_changes async with AsyncSessionLocal() as session: repo = SettingRepositoryImpl(session) await repo.set("login_subscription_ips", sub_val) await repo.set("login_allowed_ips", combined_val) settings.LOGIN_SUBSCRIPTION_IPS = sub_val settings.LOGIN_ALLOWED_IPS = combined_val await publish_setting_changes({ "login_subscription_ips": sub_val, "login_allowed_ips": combined_val, }) from server.utils.display_time import format_beijing _last_refresh_at = format_beijing(with_label=False) _last_subscription_count = len(new_sub_ips) logger.info( "Subscription IPs refreshed: %d → combined %d (+ %d manual)", len(new_sub_ips), len(combined), len(manual_ips), ) return RefreshResult( ok=True, subscription_ips=tuple(new_sub_ips), last_refresh=_last_refresh_at, subscription_count=len(new_sub_ips), ) except Exception as e: logger.error("Failed to save subscription IPs: %s", e) return RefreshResult(ok=False, error=f"保存订阅 IP 失败: {e}") def get_last_refresh_time() -> str: return _last_refresh_at def get_subscription_count() -> int: return _last_subscription_count