feat: separate subscription/manual IPs + master enable/disable toggle

Data model (separate storage):
- login_allowlist_enabled: 'true'/'false' master switch
- login_subscription_url: URL to fetch every 2h
- login_subscription_ips: last fetch result (replaced entirely each refresh)
- login_manual_ips: manually added (never overwritten by refresh)
- login_allowed_ips: combined (used by auth for legacy compat)

ip_allowlist_refresh.py:
- Saves to login_subscription_ips (replace) + rebuilds login_allowed_ips

auth_service.py:
- Login check: only runs when LOGIN_ALLOWLIST_ENABLED is 'true'
- Combines subscription_ips + manual_ips at auth time (no stale combined key needed)

settings.py:
- POST /ip-allowlist/toggle: quick enable/disable endpoint
- GET /ip-allowlist: returns subscription_ips, manual_ips separately
- POST /ip-allowlist: saves manual_ips + optional subscription_url + optional enabled

settings.html:
- Toggle switch: enable/disable with warning if list is empty
- Green notice when enabled (warns about self-lockout risk)
- Grey notice when disabled
- Subscription IPs section: read-only, auto-refresh badge
- Manual IPs section: deletable per-IP, clear all button

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 18:19:47 +08:00
parent da43960f38
commit 488838c989
5 changed files with 194 additions and 109 deletions
+61 -32
View File
@@ -341,7 +341,8 @@ async def parse_subscription(
class IpAllowlistSaveRequest(BaseModel):
manual_ips: list[str] = Field(default_factory=list)
subscription_url: Optional[str] = None # None = don't change; "" = clear
subscription_url: Optional[str] = None # None = don't change; "" = clear
enabled: Optional[bool] = None # None = don't change
@router.post("/ip-allowlist", response_model=dict)
@@ -351,18 +352,24 @@ async def set_ip_allowlist(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Save manual IPs and/or subscription URL. Triggers immediate re-fetch if URL changed."""
"""Save manual IPs, subscription URL, and/or enabled toggle."""
from server.config import settings as _settings
repo = SettingRepositoryImpl(db)
# Save manual IPs
# Toggle enabled/disabled
if payload.enabled is not None:
val = "true" if payload.enabled else "false"
await repo.set("login_allowlist_enabled", val)
_settings.LOGIN_ALLOWLIST_ENABLED = val
# 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
# Subscription URL
sub_url_changed = False
if payload.subscription_url is not None:
sub_url = payload.subscription_url.strip()
@@ -370,58 +377,80 @@ async def set_ip_allowlist(
_settings.LOGIN_SUBSCRIPTION_URL = sub_url
sub_url_changed = bool(sub_url)
# If no subscription URL, just use manual IPs as the allowlist
# If no subscription URL, rebuild combined from manual only
if not _settings.LOGIN_SUBSCRIPTION_URL:
await repo.set("login_allowed_ips", manual_val)
await repo.set("login_subscription_ips", "")
_settings.LOGIN_ALLOWED_IPS = manual_val
_settings.LOGIN_SUBSCRIPTION_IPS = ""
audit_repo = AuditLogRepositoryImpl(db)
from server.domain.models import AuditLog
ip_address = request.client.host if request.client else ""
enabled_str = "" if payload.enabled is None else f"; 白名单开关={'' if payload.enabled else ''}"
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_ip_allowlist",
target_type="setting",
detail=f"手动IP {len(manual_cleaned)}; 订阅URL {'已更新' if payload.subscription_url is not None else '未变'}",
detail=f"手动IP {len(manual_cleaned)}{enabled_str}",
ip_address=ip_address,
))
# Trigger immediate refresh if subscription URL is set
# Trigger refresh if subscription URL is present
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 "",
}
return {"success": True, "manual_count": len(manual_cleaned)}
@router.post("/ip-allowlist/toggle", response_model=dict)
async def toggle_ip_allowlist(
request: Request,
enabled: bool,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Quick toggle for allowlist enforcement (no IP list changes)."""
from server.config import settings as _settings
val = "true" if enabled else "false"
repo = SettingRepositoryImpl(db)
await repo.set("login_allowlist_enabled", val)
_settings.LOGIN_ALLOWLIST_ENABLED = val
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="toggle_ip_allowlist",
target_type="setting",
detail=f"登录IP白名单: {'启用' if enabled else '关闭'}",
ip_address=ip_address,
))
return {"success": True, "enabled": enabled}
@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()]
existing = [ip.strip() for ip in (_settings.LOGIN_MANUAL_IPS or "").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)
merged = list(dict.fromkeys(existing + new_ips))
val = ",".join(merged)
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, "manual_ips": merged}
@@ -431,13 +460,12 @@ async def remove_allowlist_ip(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Remove a single IP from the manual list and rebuild."""
"""Remove one 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
@@ -445,30 +473,31 @@ async def remove_allowlist_ip(
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)
async def get_ip_allowlist(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return current IP allowlist state including subscription URL and last refresh time."""
"""Return full allowlist state."""
from server.config import settings as _settings
from server.background.ip_allowlist_refresh import get_last_refresh_time
from server.background.ip_allowlist_refresh import get_last_refresh_time, get_subscription_count
combined_raw = _settings.LOGIN_ALLOWED_IPS or ""
allowlist_on = (_settings.LOGIN_ALLOWLIST_ENABLED or "false").lower() in ("true","1","yes","on")
sub_ips_raw = _settings.LOGIN_SUBSCRIPTION_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()]
sub_ips = [ip.strip() for ip in sub_ips_raw.split(",") if ip.strip()]
manual_ips = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
return {
"ips": combined,
"manual_ips": manual,
"count": len(combined),
"enabled": bool(combined),
"enabled": allowlist_on,
"subscription_url": _settings.LOGIN_SUBSCRIPTION_URL or "",
"subscription_ips": sub_ips,
"manual_ips": manual_ips,
"subscription_count": len(sub_ips),
"manual_count": len(manual_ips),
"total_count": len(sub_ips) + len(set(manual_ips) - set(sub_ips)),
"last_refresh": get_last_refresh_time(),
}
+9 -4
View File
@@ -81,10 +81,15 @@ class AuthService:
or {"success": False, "reason": "..."}
"""
# ── IP allowlist check ──
allowed_raw = getattr(settings, "LOGIN_ALLOWED_IPS", "") or ""
allowed_ips = [ip.strip() for ip in allowed_raw.split(",") if ip.strip()]
if allowed_ips and ip_address and ip_address not in ("", "unknown"):
if not _ip_in_allowlist(ip_address, allowed_ips):
# Only enforced when login_allowlist_enabled = "true"
allowlist_on = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
if allowlist_on in ("true", "1", "yes", "on") and ip_address and ip_address not in ("", "unknown"):
# Combine subscription IPs + manual IPs
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
manual_ips_raw = (getattr(settings, "LOGIN_MANUAL_IPS", "") or "").strip()
all_ips_raw = ",".join(filter(None, [sub_ips_raw, manual_ips_raw]))
allowed_ips = [ip.strip() for ip in all_ips_raw.split(",") if ip.strip()]
if allowed_ips and not _ip_in_allowlist(ip_address, allowed_ips):
await self._audit(
"login_ip_blocked", "admin", 0,
f"IP not in allowlist: {ip_address} (user: {username})",
+38 -30
View File
@@ -1,10 +1,11 @@
"""Background task: refresh login IP allowlist from subscription URL every 2 hours.
"""Background task: refresh login subscription IPs 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
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
"""
import asyncio
@@ -14,78 +15,85 @@ logger = logging.getLogger("nexus.ip_allowlist_refresh")
REFRESH_INTERVAL = 7200 # 2 hours
_last_refresh_at: str = ""
_last_subscription_count: int = 0
async def ip_allowlist_refresh_loop():
"""Every 2 hours, re-fetch subscription and rebuild IP allowlist."""
global _last_refresh_at
"""Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist."""
logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600)
# Do one refresh on startup if subscription URL is configured
# 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():
"""Fetch subscription and update allowlist. Non-fatal on error."""
global _last_refresh_at
"""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 # No subscription configured — nothing to do
return
logger.info("Refreshing IP allowlist from subscription: %s", sub_url[:60])
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 URL returned HTTP %s", resp.status_code)
logger.warning("Subscription returned HTTP %s", resp.status_code)
return
sub_ips = parse_subscription_content(resp.text)
new_sub_ips = parse_subscription_content(resp.text)
except Exception as e:
logger.error("Failed to refresh IP allowlist from subscription: %s", e)
logger.error("Subscription fetch failed: %s", e)
return
# Load manual IPs (preserved)
# 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()]
# Merge: subscription first, then manual (dedup)
# Combined list: subscription first, then manual (dedup)
seen: set[str] = set()
merged: list[str] = []
for ip in sub_ips + manual_ips:
combined: list[str] = []
for ip in new_sub_ips + manual_ips:
if ip not in seen:
seen.add(ip)
merged.append(ip)
combined.append(ip)
combined = ",".join(merged)
sub_val = ",".join(new_sub_ips)
combined_val = ",".join(combined)
# 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)
# Subscription IPs — replaced entirely
await repo.set("login_subscription_ips", sub_val)
# Combined — for auth check
await repo.set("login_allowed_ips", combined_val)
settings.LOGIN_ALLOWED_IPS = combined
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
settings.LOGIN_ALLOWED_IPS = combined_val
from datetime import datetime, timezone
_last_refresh_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
_last_subscription_count = len(new_sub_ips)
logger.info(
"IP allowlist refreshed: %d subscription + %d manual = %d total",
len(sub_ips), len(manual_ips), len(merged),
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
len(new_sub_ips), len(combined), len(manual_ips),
)
except Exception as e:
logger.error("Failed to save refreshed IP allowlist: %s", e)
logger.error("Failed to save subscription IPs: %s", e)
def get_last_refresh_time() -> str:
return _last_refresh_at
def get_subscription_count() -> int:
return _last_subscription_count
+11 -5
View File
@@ -73,12 +73,16 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
# 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 IP allowlist master switch — "true" = enforce allowlist, "false" = allow all
LOGIN_ALLOWLIST_ENABLED: str = "false"
# Subscription URL for auto-refreshing subscription IPs (every 2h)
LOGIN_SUBSCRIPTION_URL: str = ""
# Manually added IPs (preserved across auto-refresh)
# IPs from subscription (replaced entirely on each refresh)
LOGIN_SUBSCRIPTION_IPS: str = ""
# Manually added IPs (never touched by auto-refresh)
LOGIN_MANUAL_IPS: str = ""
# Legacy: combined view used by auth check = subscription + manual
LOGIN_ALLOWED_IPS: str = ""
# Notification toggles — each alert type can be independently enabled/disabled
# Stored as "true"/"false" strings in MySQL settings table
@@ -114,9 +118,11 @@ class Settings(BaseSettings):
"disk_alert_threshold": "DISK_ALERT_THRESHOLD",
"telegram_bot_token": "TELEGRAM_BOT_TOKEN",
"telegram_chat_id": "TELEGRAM_CHAT_ID",
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
"login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED",
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
"login_manual_ips": "LOGIN_MANUAL_IPS",
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
# Notification toggles
"notify_alert_cpu": "NOTIFY_ALERT_CPU",
"notify_alert_mem": "NOTIFY_ALERT_MEM",
+75 -38
View File
@@ -147,9 +147,25 @@
<div class="flex items-center justify-between">
<div>
<h2 class="text-white font-medium">登录 IP 白名单</h2>
<p class="text-slate-500 text-xs mt-0.5">为空则不限制来源 IP;设置后仅白名单 IP 可登录后台</p>
<p class="text-slate-500 text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
</div>
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
<!-- Master toggle -->
<div class="flex items-center gap-3">
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
<button id="allowlistToggleBtn" onclick="toggleAllowlist()"
class="w-12 h-6 rounded-full transition-colors bg-slate-700 relative"
data-enabled="false" title="点击启用/关闭白名单">
<span id="allowlistToggleDot" class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
</div>
<!-- Disabled notice -->
<div id="allowlistDisabledNotice" class="rounded-lg border border-slate-700 bg-slate-800/40 px-4 py-3 text-slate-400 text-xs">
⚪ 白名单未启用 — 任何 IP 均可登录。开启后将仅允许下方白名单 IP。
</div>
<div id="allowlistEnabledNotice" class="hidden rounded-lg border border-green-500/30 bg-green-900/10 px-4 py-3 text-green-300 text-xs">
🟢 白名单已启用 — 仅白名单内的 IP 可登录。请确保当前 IP 已在列表中,否则自己也会被锁定!
</div>
<!-- 订阅 URL(持久存储,每 2 小时自动刷新) -->
@@ -210,13 +226,22 @@
</button>
</div>
<!-- 当前白名单 -->
<div id="currentAllowlist" class="space-y-2">
<!-- 订阅节点 IP(只读,自动刷新) -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">当前白名单</label>
<button onclick="clearAllowlist()" class="text-red-400 text-xs hover:underline">清空(不限制)</button>
<label class="text-slate-400 text-xs">订阅节点 IP <span class="text-slate-600">(只读,每 2 小时自动替换)</span></label>
<span id="subIpCount" class="text-slate-600 text-xs"></span>
</div>
<div id="allowlistItems" class="text-slate-500 text-xs">加载中...</div>
<div id="subscriptionIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
</div>
<!-- 手动 IP -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">手动添加的 IP <span class="text-slate-600">(永久保留)</span></label>
<button onclick="clearManualIps()" class="text-red-400 text-xs hover:underline">清空手动 IP</button>
</div>
<div id="manualIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
</div>
</div>
@@ -612,53 +637,65 @@
}
// ── IP Allowlist ──
let _currentAllowlistIps = [];
let _subIps = [];
let _manualIps = [];
let _parsedIps = [];
let _allowlistEnabled = false;
async function loadAllowlist(){
try{
const r=await apiFetch(API+'/settings/ip-allowlist');if(!r)return;
const d=await r.json();
_currentAllowlistIps=d.ips||[];
_subIps=d.subscription_ips||[];
_manualIps=d.manual_ips||[];
renderAllowlist();
_allowlistEnabled=d.enabled||false;
// Fill subscription URL
if(d.subscription_url)document.getElementById('subUrl').value=d.subscription_url;
// Last refresh time
// Last refresh
const lrt=document.getElementById('lastRefreshTime');
if(d.last_refresh)lrt.textContent='上次刷新: '+d.last_refresh;
else if(d.subscription_url)lrt.textContent='等待首次刷新...';
// Toggle button
const btn=document.getElementById('allowlistToggleBtn');
const dot=document.getElementById('allowlistToggleDot');
btn.setAttribute('data-enabled',String(_allowlistEnabled));
btn.className=`w-12 h-6 rounded-full transition-colors ${_allowlistEnabled?'bg-brand':'bg-slate-700'} relative`;
dot.className=`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${_allowlistEnabled?'translate-x-6':'translate-x-0.5'}`;
// Notices
document.getElementById('allowlistDisabledNotice').classList.toggle('hidden',_allowlistEnabled);
document.getElementById('allowlistEnabledNotice').classList.toggle('hidden',!_allowlistEnabled);
// Status badge
const status=document.getElementById('ipAllowlistStatus');
if(d.enabled){
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';
if(_allowlistEnabled){
status.textContent=`启用 · ${d.total_count}`;
status.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400 border border-green-500/30';
}else{
status.textContent='未启用(不限制)';
status.textContent='已关闭';
status.className='text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
}
// Subscription IPs (read-only)
document.getElementById('subIpCount').textContent=`${_subIps.length}`;
document.getElementById('subscriptionIpsList').innerHTML=_subIps.length
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-slate-700 bg-slate-800 text-xs font-mono text-slate-400">${esc(ip)}</span>`).join('')+'</div>'
:'<span class="text-slate-600">(空 — 尚未刷新或未配置订阅)</span>';
// Manual IPs (deletable)
document.getElementById('manualIpsList').innerHTML=_manualIps.length
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-slate-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-slate-600">(空)</span>';
}catch(e){}
}
function renderAllowlist(){
const el=document.getElementById('allowlistItems');
if(!_currentAllowlistIps.length){
el.innerHTML='<span class="text-slate-500">(空 — 不限制来源 IP</span>';
return;
async function toggleAllowlist(){
const btn=document.getElementById('allowlistToggleBtn');
const nowEnabled=btn.getAttribute('data-enabled')==='true';
const newEnabled=!nowEnabled;
if(newEnabled&&_subIps.length===0&&_manualIps.length===0){
toast('warning','白名单为空,启用后无人可以登录!请先添加 IP 再启用。');return;
}
const manualSet=new Set(_manualIps);
el.innerHTML='<div class="flex flex-wrap gap-1.5">'+
_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>';
if(newEnabled&&!confirm('确认启用 IP 白名单?\n启用后非白名单 IP 将无法登录,请确认当前 IP 已在列表中。'))return;
const r=await apiFetch(API+'/settings/ip-allowlist/toggle?enabled='+newEnabled,{method:'POST',headers:apiHeadersJSON()});
if(r&&r.ok){toast('success',newEnabled?'白名单已启用':'白名单已关闭');loadAllowlist();}
else toast('error','操作失败');
}
async function saveSubscriptionUrl(){
@@ -668,7 +705,7 @@
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)}
if(r&&r.ok){toast('success',url?'订阅地址已保存,正在刷新节点 IP...':'订阅地址已清空');setTimeout(loadAllowlist,3500)}
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
}finally{btn.disabled=false;btn.textContent='保存'}
}
@@ -700,7 +737,7 @@
<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='🔍 预览当前订阅节点 IP'}
}
@@ -736,10 +773,10 @@
else toast('error','移除失败');
}
function clearAllowlist(){
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)}});
async function clearManualIps(){
if(!confirm('确定清空所有手动添加的 IP?订阅节点不受影响。'))return;
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({manual_ips:[]})});
if(r&&r.ok){toast('success','手动 IP 已清空');setTimeout(loadAllowlist,1000)}
}
// ── Init ──