fix: settings 安全加固 + UX 迭代 — 10项修复
安全修复:
- S-01 SSRF: parse-subscription 加私有IP封禁+重定向限制+响应大小限制
- S-02 值校验: PUT /{key} 加 key 白名单 + INT 范围校验(1-1000/1-100/10-600)
- S-05 重登录: 改密码成功后跳转login.html+绿色提示
- S-07 审计: add_manual_ips/remove_allowlist_ip 补全审计日志
- S-09 TOTP: 已启用TOTP时改密码需输入验证码
UX改进:
- UX-01: 密码框focus ring + 设置项input type映射(number/url/text)
- UX-02: 改密码按钮loading状态(disabled+提交中...)
- UX-03: 3处空catch块→console.error+toast
- UX-04: 保存失败时reloadSettings恢复原值
- UX-05: IP格式校验(前端正则+后端Pydantic model_validator)
This commit is contained in:
@@ -99,6 +99,7 @@ class TotpVerifyRequest(BaseModel):
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(..., min_length=1, max_length=255)
|
||||
new_password: str = Field(..., min_length=6, max_length=255)
|
||||
totp_code: Optional[str] = None # S-09: required if TOTP enabled
|
||||
|
||||
|
||||
class WebsshTokenRequest(BaseModel):
|
||||
@@ -285,6 +286,14 @@ async def change_password(
|
||||
if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
# S-09: If TOTP is enabled, require TOTP code for password change
|
||||
if current_admin.totp_enabled:
|
||||
if not payload.totp_code:
|
||||
raise HTTPException(status_code=400, detail="已启用 TOTP 的账户修改密码需要验证码")
|
||||
from server.application.services.auth_service import AuthService
|
||||
if not AuthService._verify_totp(payload.totp_code, current_admin.totp_secret):
|
||||
raise HTTPException(status_code=400, detail="TOTP 验证码错误")
|
||||
|
||||
# Hash new password
|
||||
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
+135
-8
@@ -7,9 +7,13 @@ Sensitive values are masked in GET responses.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
@@ -29,6 +33,52 @@ IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
|
||||
# Settings whose values are masked in GET responses
|
||||
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "telegram_bot_token"}
|
||||
|
||||
# S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles)
|
||||
MUTABLE_KEYS: set[str] = {
|
||||
"system_name", "system_title", "db_pool_size", "db_max_overflow",
|
||||
"redis_url", "heartbeat_timeout", "api_base_url",
|
||||
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
|
||||
"telegram_bot_token", "telegram_chat_id",
|
||||
"login_allowlist_enabled", "login_subscription_url",
|
||||
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
|
||||
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
|
||||
"notify_recovery", "notify_time_drift",
|
||||
"notify_system_redis", "notify_system_mysql", "notify_restart",
|
||||
}
|
||||
|
||||
# S-02: Per-key validation rules: (type, min, max) for int settings
|
||||
SETTING_VALIDATORS: dict[str, tuple] = {
|
||||
"db_pool_size": (int, 1, 1000),
|
||||
"db_max_overflow": (int, 1, 1000),
|
||||
"heartbeat_timeout": (int, 10, 600),
|
||||
"cpu_alert_threshold": (int, 1, 100),
|
||||
"mem_alert_threshold": (int, 1, 100),
|
||||
"disk_alert_threshold": (int, 1, 100),
|
||||
}
|
||||
|
||||
|
||||
# S-01: SSRF protection — check if hostname resolves to private/link-local IP
|
||||
def _is_private_host(hostname: str) -> bool:
|
||||
"""Check if hostname resolves to any private/loopback/link-local IP."""
|
||||
if not hostname:
|
||||
return True
|
||||
# Direct IP check
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
||||
except ValueError:
|
||||
pass # Not a literal IP, do DNS resolution
|
||||
# DNS resolution check (blocks DNS rebinding too)
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
|
||||
for _family, _, _, _, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
except (socket.gaierror, ValueError, OSError):
|
||||
return True # DNS failure → block
|
||||
return False
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@@ -135,6 +185,19 @@ async def set_setting(
|
||||
"""
|
||||
if key in IMMUTABLE_KEYS:
|
||||
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
|
||||
if key not in MUTABLE_KEYS:
|
||||
raise HTTPException(status_code=403, detail=f"未知设置项: {key}")
|
||||
|
||||
# S-02: Validate value type and range for int settings
|
||||
val_str = str(payload.value)
|
||||
if key in SETTING_VALIDATORS:
|
||||
expected_type, min_val, max_val = SETTING_VALIDATORS[key]
|
||||
try:
|
||||
int_val = int(val_str)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail=f"'{key}' 必须是整数") from None
|
||||
if int_val < min_val or int_val > max_val:
|
||||
raise HTTPException(status_code=400, detail=f"'{key}' 范围: {min_val}-{max_val}")
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
setting = await repo.set(key, str(payload.value))
|
||||
@@ -790,8 +853,22 @@ async def telegram_get_chats(
|
||||
|
||||
# ── IP Allowlist ──
|
||||
|
||||
_IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$")
|
||||
_DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$")
|
||||
|
||||
|
||||
class IpAllowlistRequest(BaseModel):
|
||||
ips: list[str] = Field(..., description="IP or hostname list to set as allowlist")
|
||||
ips: list[str] = Field(..., min_length=1, max_length=100, description="IP or hostname list")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_ips(self):
|
||||
for raw in self.ips:
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
|
||||
raise ValueError(f"无效的 IP/CIDR/域名: {ip}")
|
||||
return self
|
||||
|
||||
|
||||
class SubscriptionParseRequest(BaseModel):
|
||||
@@ -810,19 +887,47 @@ async def parse_subscription(
|
||||
Supports SS, VMess, VLESS, Trojan, Hysteria2 formats.
|
||||
Returns the parsed host list for the user to review before saving.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
import httpx
|
||||
from server.infrastructure.subscription_parser import parse_subscription_content
|
||||
|
||||
url = payload.url.strip()
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
|
||||
|
||||
# S-01: SSRF protection — block private/link-local/loopback IPs
|
||||
hostname = parsed.hostname or ""
|
||||
if _is_private_host(hostname):
|
||||
raise HTTPException(status_code=400, detail="不允许访问内网地址")
|
||||
|
||||
MAX_BODY = 1_000_000 # 1 MB
|
||||
MAX_REDIRECTS = 3
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
|
||||
raw = resp.text
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client:
|
||||
resp = await client.get(url)
|
||||
# Manual redirect following with SSRF check on each hop
|
||||
redirects = 0
|
||||
while resp.is_redirect and redirects < MAX_REDIRECTS:
|
||||
redirects += 1
|
||||
location = resp.headers.get("location", "")
|
||||
if not location:
|
||||
break
|
||||
# Resolve relative redirects
|
||||
redirect_url = str(httpx.URL(location).resolve_with(url))
|
||||
redir_parsed = urlparse(redirect_url)
|
||||
redir_host = redir_parsed.hostname or ""
|
||||
if _is_private_host(redir_host):
|
||||
raise HTTPException(status_code=400, detail="订阅链接重定向到内网地址,已阻止")
|
||||
url = redirect_url
|
||||
resp = await client.get(redirect_url)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
|
||||
# S-01: Limit response body size
|
||||
if len(resp.content) > MAX_BODY:
|
||||
raise HTTPException(status_code=400, detail="订阅内容超过 1MB 限制")
|
||||
raw = resp.text
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=400, detail="请求订阅链接超时(15s)") from None
|
||||
except HTTPException:
|
||||
@@ -942,6 +1047,7 @@ async def toggle_ip_allowlist(
|
||||
@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),
|
||||
):
|
||||
@@ -956,6 +1062,16 @@ async def add_manual_ips(
|
||||
await repo.set("login_manual_ips", val)
|
||||
_settings.LOGIN_MANUAL_IPS = val
|
||||
|
||||
# S-07: Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="add_manual_ips",
|
||||
target_type="setting",
|
||||
detail=f"添加 {len(new_ips)} 条手动 IP: {', '.join(new_ips[:5])}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
@@ -965,6 +1081,7 @@ async def add_manual_ips(
|
||||
@router.delete("/ip-allowlist/ip", response_model=dict)
|
||||
async def remove_allowlist_ip(
|
||||
ip: str,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -978,6 +1095,16 @@ async def remove_allowlist_ip(
|
||||
await repo.set("login_manual_ips", val)
|
||||
_settings.LOGIN_MANUAL_IPS = val
|
||||
|
||||
# S-07: Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="remove_allowlist_ip",
|
||||
target_type="setting",
|
||||
detail=f"移除白名单 IP: {ip}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
|
||||
@@ -477,7 +477,8 @@ class AuthService:
|
||||
"""Verify password against stored bcrypt hash"""
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
|
||||
def _verify_totp(self, code: str, secret: str) -> bool:
|
||||
@staticmethod
|
||||
def _verify_totp(code: str, secret: str) -> bool:
|
||||
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
|
||||
import base64
|
||||
key = base64.b32decode(secret, casefold=True)
|
||||
|
||||
@@ -317,6 +317,18 @@
|
||||
}
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
// S-05: Show message from URL parameter (e.g., after password change redirect)
|
||||
(function() {
|
||||
const msg = new URLSearchParams(window.location.search).get('msg');
|
||||
if (msg === 'password_changed') {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = '✅ 密码已修改,请重新登录。';
|
||||
el.className = 'mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm text-center';
|
||||
el.classList.remove('hidden');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
+37
-17
@@ -102,12 +102,13 @@
|
||||
<!-- Change Password -->
|
||||
<div>
|
||||
<span class="text-white text-sm font-medium">修改密码</span>
|
||||
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。</p>
|
||||
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
|
||||
<div class="space-y-2 max-w-sm">
|
||||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="changePassword()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
|
||||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="totpCodeForPassword" type="text" inputmode="numeric" maxlength="6" placeholder="TOTP 验证码 (已启用2FA时必填)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<button onclick="changePassword()" id="changePwBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,6 +280,9 @@
|
||||
<script src="/app/vendor/qrious.min.js"></script>
|
||||
<script>
|
||||
initLayout('settings');
|
||||
// UX-01: Input type mapping for settings fields
|
||||
const INPUT_TYPES={db_pool_size:'number',db_max_overflow:'number',heartbeat_timeout:'number',
|
||||
cpu_alert_threshold:'number',mem_alert_threshold:'number',disk_alert_threshold:'number',api_base_url:'url'};
|
||||
const SECTIONS={
|
||||
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
|
||||
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
|
||||
@@ -310,7 +314,7 @@
|
||||
}
|
||||
return `<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="set_${key}" value="${esc(val)}" type="${INPUT_TYPES[key]||'text'}" ${INPUT_TYPES[key]==='number'?'min="1"':''} class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -325,7 +329,7 @@
|
||||
// Notification toggles
|
||||
renderNotifyToggles(_allSettings);
|
||||
|
||||
}catch(e){}
|
||||
}catch(e){console.error(e);toast('error','加载设置失败')}
|
||||
}
|
||||
|
||||
function renderApiKeySection(){
|
||||
@@ -476,7 +480,8 @@
|
||||
async function saveSetting(key){
|
||||
const v=document.getElementById('set_'+key).value;
|
||||
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
|
||||
if(r&&r.ok)toast('success','设置已保存');else toast('error','保存失败');
|
||||
if(r&&r.ok)toast('success','设置已保存');
|
||||
else{toast('error','保存失败');loadSettings();}
|
||||
}
|
||||
|
||||
// ── TOTP Management ──
|
||||
@@ -488,7 +493,7 @@
|
||||
const admin=await r.json();
|
||||
_currentAdminId=admin.id;
|
||||
updateTotpUI(admin.totp_enabled);
|
||||
}catch(e){}
|
||||
}catch(e){console.error(e);toast('error','加载TOTP状态失败')}
|
||||
}
|
||||
|
||||
function updateTotpUI(enabled){
|
||||
@@ -616,28 +621,38 @@
|
||||
const current=document.getElementById('currentPassword').value;
|
||||
const newPw=document.getElementById('newPassword').value;
|
||||
const confirm=document.getElementById('confirmPassword').value;
|
||||
const totpCode=document.getElementById('totpCodeForPassword')?.value||'';
|
||||
|
||||
if(!current||!newPw||!confirm){toast('warning','请填写所有密码字段');return}
|
||||
if(newPw.length<6){toast('warning','新密码至少6位');return}
|
||||
if(newPw!==confirm){toast('warning','两次输入的新密码不一致');return}
|
||||
|
||||
// UX-02: Loading state
|
||||
const btn=document.getElementById('changePwBtn');
|
||||
btn.disabled=true;btn.textContent='提交中...';
|
||||
try{
|
||||
const body={current_password:current,new_password:newPw};
|
||||
if(totpCode)body.totp_code=totpCode;
|
||||
const r=await apiFetch(API+'/auth/password',{
|
||||
method:'PUT',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({current_password:current,new_password:newPw})
|
||||
method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)
|
||||
});
|
||||
if(!r){toast('error','请求失败');return}
|
||||
if(r.ok){
|
||||
const data=await r.json();
|
||||
toast('success','密码已修改');
|
||||
document.getElementById('currentPassword').value='';
|
||||
document.getElementById('newPassword').value='';
|
||||
document.getElementById('confirmPassword').value='';
|
||||
toast('success','密码已修改,正在跳转...');
|
||||
// S-05: Redirect to login after password change
|
||||
setTimeout(()=>{
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('token_expires');
|
||||
localStorage.removeItem('admin');
|
||||
localStorage.removeItem('last_activity');
|
||||
window.location.href='/app/login.html?msg=password_changed';
|
||||
},800);
|
||||
}else{
|
||||
const err=await r.json().catch(()=>({}));
|
||||
toast('error',err.detail||'修改失败');
|
||||
}
|
||||
}catch(e){toast('error','修改请求失败')}
|
||||
finally{btn.disabled=false;btn.textContent='修改密码'}
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
@@ -813,7 +828,7 @@
|
||||
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){}
|
||||
}catch(e){console.error(e);toast('error','加载白名单失败')}
|
||||
}
|
||||
|
||||
async function toggleAllowlist(){
|
||||
@@ -888,6 +903,11 @@
|
||||
const raw=document.getElementById('manualIps').value;
|
||||
const newIps=raw.split(/[\n,]/).map(s=>s.trim()).filter(Boolean);
|
||||
if(!newIps.length){toast('warning','请输入 IP');return}
|
||||
// UX-05: Frontend IP/CIDR/domain format validation
|
||||
const ipRe=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?$/;
|
||||
const domainRe=/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/;
|
||||
const invalid=newIps.filter(ip=>!(ipRe.test(ip)||domainRe.test(ip)));
|
||||
if(invalid.length){toast('error','格式无效: '+invalid.join(', '));return}
|
||||
await addManualIpsToList(newIps);
|
||||
document.getElementById('manualIps').value='';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user