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 ──
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user