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 ──
+91
View File
@@ -0,0 +1,91 @@
"""Background task: refresh login IP allowlist from subscription URL every 2 hours.
Flow:
1. Read LOGIN_SUBSCRIPTION_URL from settings
2. Fetch & parse proxy nodes → subscription IPs
3. Merge with LOGIN_MANUAL_IPS (manually added, always preserved)
4. Write combined list to LOGIN_ALLOWED_IPS + settings table
"""
import asyncio
import logging
logger = logging.getLogger("nexus.ip_allowlist_refresh")
REFRESH_INTERVAL = 7200 # 2 hours
_last_refresh_at: str = ""
async def ip_allowlist_refresh_loop():
"""Every 2 hours, re-fetch subscription and rebuild IP allowlist."""
global _last_refresh_at
logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600)
# Do one refresh on startup if subscription URL is configured
await asyncio.sleep(5)
await _do_refresh()
while True:
await asyncio.sleep(REFRESH_INTERVAL)
await _do_refresh()
async def _do_refresh():
"""Fetch subscription and update allowlist. Non-fatal on error."""
global _last_refresh_at
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 # No subscription configured — nothing to do
logger.info("Refreshing IP allowlist from subscription: %s", sub_url[:60])
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 URL returned HTTP %s", resp.status_code)
return
sub_ips = parse_subscription_content(resp.text)
except Exception as e:
logger.error("Failed to refresh IP allowlist from subscription: %s", e)
return
# Load manual IPs (preserved)
manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip()
manual_ips = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
# Merge: subscription first, then manual (dedup)
seen: set[str] = set()
merged: list[str] = []
for ip in sub_ips + manual_ips:
if ip not in seen:
seen.add(ip)
merged.append(ip)
combined = ",".join(merged)
# Write to settings table + update runtime config
try:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
async with AsyncSessionLocal() as session:
repo = SettingRepositoryImpl(session)
await repo.set("login_allowed_ips", combined)
settings.LOGIN_ALLOWED_IPS = combined
from datetime import datetime, timezone
_last_refresh_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
logger.info(
"IP allowlist refreshed: %d subscription + %d manual = %d total",
len(sub_ips), len(manual_ips), len(merged),
)
except Exception as e:
logger.error("Failed to save refreshed IP allowlist: %s", e)
def get_last_refresh_time() -> str:
return _last_refresh_at
+6
View File
@@ -75,6 +75,10 @@ class Settings(BaseSettings):
# Login IP allowlist — empty means allow all; comma-separated IPs/CIDRs
LOGIN_ALLOWED_IPS: str = ""
# Subscription URL for auto-refreshing IP allowlist (every 2h)
LOGIN_SUBSCRIPTION_URL: str = ""
# Manually added IPs (preserved across auto-refresh)
LOGIN_MANUAL_IPS: str = ""
# Notification toggles — each alert type can be independently enabled/disabled
# Stored as "true"/"false" strings in MySQL settings table
@@ -111,6 +115,8 @@ class Settings(BaseSettings):
"telegram_bot_token": "TELEGRAM_BOT_TOKEN",
"telegram_chat_id": "TELEGRAM_CHAT_ID",
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
"login_manual_ips": "LOGIN_MANUAL_IPS",
# Notification toggles
"notify_alert_cpu": "NOTIFY_ALERT_CPU",
"notify_alert_mem": "NOTIFY_ALERT_MEM",
+3 -1
View File
@@ -64,6 +64,7 @@ from server.background.script_execution_flush import script_execution_flush_loop
from server.background.self_monitor import self_monitor_loop
from server.background.schedule_runner import schedule_runner_loop
from server.background.retry_runner import retry_runner_loop
from server.background.ip_allowlist_refresh import ip_allowlist_refresh_loop
logger = logging.getLogger("nexus")
@@ -224,8 +225,9 @@ async def lifespan(app: FastAPI):
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor")
task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner")
task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner")
task_ip_refresh = asyncio.create_task(ip_allowlist_refresh_loop(), name="ip_allowlist_refresh")
_background_tasks.extend([
task_flush, task_script_flush, task_monitor, task_schedule, task_retry,
task_flush, task_script_flush, task_monitor, task_schedule, task_retry, task_ip_refresh,
])
logger.info("Primary worker — background tasks launched")
else:
+89 -36
View File
@@ -152,18 +152,36 @@
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
</div>
<!-- 订阅解析 -->
<div class="space-y-2">
<label class="block text-slate-400 text-xs">从代理订阅链接导入节点 IP</label>
<!-- 订阅 URL(持久存储,每 2 小时自动刷新) -->
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-slate-300 text-sm font-medium">代理订阅地址(自动刷新)</label>
<span id="lastRefreshTime" class="text-slate-600 text-xs"></span>
</div>
<div class="flex gap-2">
<input id="subUrl" type="url" placeholder="https://your-subscription-url..."
class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
<button onclick="parseSubscription()" id="parseSubBtn"
class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition shrink-0">
🔍 解析
class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
<button onclick="saveSubscriptionUrl()" id="saveSubBtn"
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition shrink-0">
保存
</button>
<button onclick="forceRefresh()" id="refreshSubBtn"
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition shrink-0" title="立即刷新">
🔄
</button>
</div>
<p class="text-slate-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 格式的订阅链接</p>
<p class="text-slate-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
</div>
<!-- 解析结果预览 -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<button onclick="previewSubscription()" id="parseSubBtn"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
🔍 预览当前订阅节点 IP
</button>
<span class="text-slate-600 text-xs">(不更改白名单,仅查看)</span>
</div>
</div>
<!-- 解析结果 (待确认) -->
@@ -595,6 +613,7 @@
// ── IP Allowlist ──
let _currentAllowlistIps = [];
let _manualIps = [];
let _parsedIps = [];
async function loadAllowlist(){
@@ -602,10 +621,18 @@
const r=await apiFetch(API+'/settings/ip-allowlist');if(!r)return;
const d=await r.json();
_currentAllowlistIps=d.ips||[];
_manualIps=d.manual_ips||[];
renderAllowlist();
// Fill subscription URL
if(d.subscription_url)document.getElementById('subUrl').value=d.subscription_url;
// Last refresh time
const lrt=document.getElementById('lastRefreshTime');
if(d.last_refresh)lrt.textContent='上次刷新: '+d.last_refresh;
else if(d.subscription_url)lrt.textContent='等待首次刷新...';
// Status badge
const status=document.getElementById('ipAllowlistStatus');
if(d.enabled){
status.textContent=`启用 · ${d.count}`;
status.textContent=`启用 · ${d.count}${d.subscription_url?' · 订阅自动刷新':''}`;
status.className='text-xs px-2 py-0.5 rounded-full bg-brand/20 text-brand-light border border-brand/30';
}else{
status.textContent='未启用(不限制)';
@@ -620,26 +647,46 @@
el.innerHTML='<span class="text-slate-500">(空 — 不限制来源 IP</span>';
return;
}
const manualSet=new Set(_manualIps);
el.innerHTML='<div class="flex flex-wrap gap-1.5">'+
_currentAllowlistIps.map(ip=>`
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-slate-800 border border-slate-700 text-xs text-slate-300 font-mono">
${esc(ip)}
<button onclick="removeIp('${escAttr(ip)}')" class="text-slate-500 hover:text-red-400 transition">×</button>
</span>`).join('')+
'</div>';
_currentAllowlistIps.map(ip=>{
const isManual=manualSet.has(ip);
const badge=isManual?'border-brand/40 bg-brand/10 text-brand-light':'border-slate-700 bg-slate-800 text-slate-400';
const removeBtn=isManual?`<button onclick="removeManualIp('${escAttr(ip)}')" class="text-slate-500 hover:text-red-400 transition ml-1">×</button>`:'';
const tag=isManual?'':'<span class="text-slate-600 text-[10px] ml-0.5">订阅</span>';
return `<span class="inline-flex items-center px-2 py-0.5 rounded border ${badge} text-xs font-mono">
${esc(ip)}${tag}${removeBtn}
</span>`;
}).join('')+
'</div><p class="text-slate-600 text-xs mt-2">蓝色=手动添加(可删除)· 灰色=订阅节点(自动刷新后更新)</p>';
}
async function saveAllowlist(ips){
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({ips})});
if(r&&r.ok){const d=await r.json();_currentAllowlistIps=d.ips||[];renderAllowlist();toast('success',`白名单已保存(${d.count} 条)`);loadAllowlist();}
else toast('error','保存失败');
}
async function parseSubscription(){
async function saveSubscriptionUrl(){
const url=document.getElementById('subUrl').value.trim();
if(!url){toast('warning','请输入订阅链接');return}
const btn=document.getElementById('saveSubBtn');
btn.disabled=true;btn.textContent='保存中...';
try{
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({manual_ips:_manualIps,subscription_url:url})});
if(r&&r.ok){toast('success',url?'订阅地址已保存,正在刷新...':'订阅地址已清空');setTimeout(loadAllowlist,3000)}
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
}finally{btn.disabled=false;btn.textContent='保存'}
}
async function forceRefresh(){
const btn=document.getElementById('refreshSubBtn');
btn.textContent='⏳';btn.disabled=true;
const url=(document.getElementById('subUrl').value||'').trim();
if(!url){toast('warning','请先填写订阅地址');btn.textContent='🔄';btn.disabled=false;return}
await saveSubscriptionUrl();
setTimeout(()=>{btn.textContent='🔄';btn.disabled=false;loadAllowlist()},4000);
}
async function previewSubscription(){
const url=(document.getElementById('subUrl').value||'').trim();
if(!url){toast('warning','请先填写订阅地址');return}
const btn=document.getElementById('parseSubBtn');
btn.disabled=true;btn.textContent='解析中...';
btn.disabled=true;btn.textContent='预览中...';
try{
const r=await apiFetch(API+'/settings/ip-allowlist/parse-subscription',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({url})});
if(!r||!r.ok){const e=await r?.json().catch(()=>({detail:'解析失败'}));toast('error',e.detail||'解析失败');return}
@@ -653,9 +700,9 @@
<input type="checkbox" checked class="parsed-ip-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand" data-ip="${escAttr(ip)}">
<span class="font-mono text-xs text-slate-300">${esc(ip)}</span>
</label>`).join('');
toast('success',`解析${d.count} 个节点 IP`);
toast('success',`预览${d.count} 个节点 IP(订阅自动刷新时会全部加入)`);
}catch(e){toast('error','请求失败: '+e.message)}
finally{btn.disabled=false;btn.textContent='🔍 解析'}
finally{btn.disabled=false;btn.textContent='🔍 预览当前订阅节点 IP'}
}
function selectAllParsed(checked){
@@ -665,28 +712,34 @@
function addSelectedToWhitelist(){
const selected=Array.from(document.querySelectorAll('.parsed-ip-cb:checked')).map(cb=>cb.dataset.ip);
if(!selected.length){toast('warning','请勾选至少一个 IP');return}
const merged=[...new Set([..._currentAllowlistIps,...selected])];
saveAllowlist(merged);
addManualIpsToList(selected);
document.getElementById('parsedIpsSection').classList.add('hidden');
}
function addManualIps(){
async function addManualIps(){
const raw=document.getElementById('manualIps').value;
const newIps=raw.split(/[\n,]/).map(s=>s.trim()).filter(Boolean);
if(!newIps.length){toast('warning','请输入 IP');return}
const merged=[...new Set([..._currentAllowlistIps,...newIps])];
saveAllowlist(merged);
await addManualIpsToList(newIps);
document.getElementById('manualIps').value='';
}
function removeIp(ip){
const remaining=_currentAllowlistIps.filter(i=>i!==ip);
saveAllowlist(remaining);
async function addManualIpsToList(newIps){
const r=await apiFetch(API+'/settings/ip-allowlist/manual',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({ips:newIps})});
if(r&&r.ok){toast('success',`已添加 ${newIps.length} 条手动 IP`);setTimeout(loadAllowlist,1500)}
else toast('error','添加失败');
}
async function removeManualIp(ip){
const r=await apiFetch(API+'/settings/ip-allowlist/ip?ip='+encodeURIComponent(ip),{method:'DELETE',headers:apiHeadersJSON()});
if(r&&r.ok){toast('success','已移除: '+ip);setTimeout(loadAllowlist,1000)}
else toast('error','移除失败');
}
function clearAllowlist(){
if(!confirm('确定清空白名单?清空后任何 IP 均可登录。'))return;
saveAllowlist([]);
if(!confirm('确定清空所有手动 IP?订阅节点将在下次自动刷新时重建。'))return;
apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({manual_ips:[],subscription_url:null})})
.then(r=>{if(r&&r.ok){toast('success','手动 IP 已清空');setTimeout(loadAllowlist,1000)}});
}
// ── Init ──