fix: harden cross-browser dashboard interactions

This commit is contained in:
Aimili
2026-08-27 01:44:09 +08:00
parent bbc2457498
commit f9361d0a7d
3 changed files with 354 additions and 122 deletions
+3
View File
@@ -254,6 +254,9 @@ def socks5_client(client: socket.socket, first_byte: bytes) -> None:
return
client.sendall(b"\x01\x00")
else:
if 0 not in methods:
client.sendall(b"\x05\xff")
return
client.sendall(b"\x05\x00")
version, command, _, address_type = recv_exact(client, 4)
if version != 5 or command != 1:
+60
View File
@@ -98,6 +98,7 @@ class ManagerLogicTests(unittest.TestCase):
manager.last_proxy_failure_node_id = ""
manager.background_refill_thread = None
manager.background_refill_cancel_event.clear()
manager.active_sessions.clear()
def tearDown(self) -> None:
if manager.connection_attempt_lock.locked():
@@ -407,6 +408,40 @@ class ManagerLogicTests(unittest.TestCase):
self.assertIn('typeof document.hidden !== "boolean" || !document.hidden', manager.INDEX_HTML)
self.assertEqual(500, manager.WEB_LOG_MAX_ENTRIES)
def test_web_dashboard_has_cross_browser_interaction_safeguards(self) -> None:
self.assertNotIn("fonts.googleapis.com", manager.LOGIN_HTML)
self.assertNotIn("fonts.googleapis.com", manager.INDEX_HTML)
self.assertIn('const pwd = document.getElementById("password").value;', manager.LOGIN_HTML)
self.assertIn('const password = $("cred_password").value;', manager.INDEX_HTML)
self.assertIn("function fetchWithTimeout", manager.LOGIN_HTML)
self.assertIn("function fetchWithTimeout", manager.INDEX_HTML)
self.assertNotIn("await fetch(", manager.INDEX_HTML)
self.assertIn('role="dialog" aria-modal="true"', manager.INDEX_HTML)
self.assertIn('aria-label="关闭网页安全设置"', manager.INDEX_HTML)
self.assertIn('class="option-card active" data-value="auto" aria-pressed="true"', manager.INDEX_HTML)
self.assertIn('class="vps-recommend-tab"', manager.INDEX_HTML)
self.assertIn('position: static;', manager.INDEX_HTML)
self.assertIn('-webkit-overflow-scrolling: touch;', manager.INDEX_HTML)
self.assertIn('formatUrlHost(window.location.hostname)', manager.INDEX_HTML)
self.assertNotIn('id="status" class="status" style="display: none;"', manager.INDEX_HTML)
self.assertIn('${esc(localProxy)}', manager.INDEX_HTML)
self.assertIn('${esc(statusMessage)}', manager.INDEX_HTML)
def test_random_password_uses_cryptographic_randomness(self) -> None:
with mock.patch.object(manager.secrets, "choice", side_effect=list("aA0aA0aA0aA0")) as choice:
password = manager.generate_random_password()
self.assertEqual("aA0aA0aA0aA0", password)
self.assertEqual(12, choice.call_count)
def test_expired_sessions_are_removed(self) -> None:
manager.active_sessions.update({"expired": 99.0, "active": 101.0})
removed = manager.purge_expired_sessions(now=100.0)
self.assertEqual(1, removed)
self.assertEqual({"active": 101.0}, manager.active_sessions)
def test_web_log_reader_only_returns_recent_valid_entries(self) -> None:
log_file = manager.DATA_DIR / "logs" / "current.json"
log_file.parent.mkdir(parents=True)
@@ -597,6 +632,31 @@ class ManagerLogicTests(unittest.TestCase):
class ProxyServerConcurrencyTests(unittest.TestCase):
def test_socks5_rejects_client_without_no_auth_method(self) -> None:
class Client:
def __init__(self):
self.incoming = bytearray(b"\x01\x02")
self.sent = bytearray()
self.closed = False
def recv(self, size):
chunk = self.incoming[:size]
del self.incoming[:size]
return bytes(chunk)
def sendall(self, data):
self.sent.extend(data)
def close(self):
self.closed = True
client = Client()
with mock.patch.object(proxy_server, "proxy_auth_enabled", return_value=False):
proxy_server.socks5_client(client, b"\x05")
self.assertEqual(b"\x05\xff", bytes(client.sent))
self.assertTrue(client.closed)
def test_each_proxy_worker_keeps_its_accepted_socket(self) -> None:
class Client:
def __init__(self, name):
+291 -122
View File
@@ -7,6 +7,7 @@ import json
import os
import queue
import re
import secrets
import select
import shlex
import signal
@@ -179,6 +180,17 @@ server_start_time = time.time()
class ConnectionCancelled(RuntimeError):
pass
def purge_expired_sessions(now: float | None = None) -> int:
current_time = time.time() if now is None else now
with lock:
expired_tokens = [
token for token, expires_at in active_sessions.items()
if expires_at <= current_time
]
for token in expired_tokens:
active_sessions.pop(token, None)
return len(expired_tokens)
def ensure_dirs() -> None:
DATA_DIR.mkdir(exist_ok=True, parents=True)
CONFIG_DIR.mkdir(exist_ok=True, parents=True)
@@ -219,13 +231,12 @@ def read_json(path: Path, default: Any) -> Any:
return default
import hashlib
import random
def generate_random_password() -> str:
import string
chars = string.ascii_letters + string.digits
while True:
pwd = "".join(random.choices(chars, k=12))
pwd = "".join(secrets.choice(chars) for _ in range(12))
# Ensure it contains at least one lowercase, one uppercase, and one digit
has_lower = any(c.islower() for c in pwd)
has_upper = any(c.isupper() for c in pwd)
@@ -237,7 +248,7 @@ def generate_random_username() -> str:
import string
chars = string.ascii_letters + string.digits
while True:
uname = "".join(random.choices(chars, k=12))
uname = "".join(secrets.choice(chars) for _ in range(12))
# Ensure it starts with a letter and contains at least one lowercase, one uppercase, and one digit
if uname[0].isalpha():
has_lower = any(c.islower() for c in uname)
@@ -2499,7 +2510,6 @@ LOGIN_HTML = r"""<!DOCTYPE html>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AimiliVPN - 安全登录</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #090d16;
@@ -2517,7 +2527,7 @@ LOGIN_HTML = r"""<!DOCTYPE html>
body {
margin: 0;
padding: 0;
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-dark);
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
@@ -2717,10 +2727,21 @@ LOGIN_HTML = r"""<!DOCTYPE html>
</div>
<script>
function fetchWithTimeout(resource, options = {}, timeoutMs = 20000) {
if (typeof AbortController === "undefined") return fetch(resource, options);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
return fetch(resource, Object.assign({}, options, { signal: controller.signal }))
.then(
response => { window.clearTimeout(timeoutId); return response; },
error => { window.clearTimeout(timeoutId); throw error; }
);
}
async function handleLogin(e) {
e.preventDefault();
const uname = document.getElementById("username").value.trim();
const pwd = document.getElementById("password").value.trim();
const pwd = document.getElementById("password").value;
const errorText = document.getElementById("error_text");
const submitBtn = document.getElementById("submit_btn");
@@ -2729,11 +2750,11 @@ LOGIN_HTML = r"""<!DOCTYPE html>
submitBtn.querySelector("span").textContent = "正在验证...";
try {
const response = await fetch("./api/login", {
const response = await fetchWithTimeout("./api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: uname, password: pwd })
});
}, 20000);
const data = await response.json();
if (response.ok && data.ok) {
@@ -2745,7 +2766,9 @@ LOGIN_HTML = r"""<!DOCTYPE html>
submitBtn.querySelector("span").textContent = "登录";
}
} catch (err) {
errorText.textContent = "连接服务器失败,请稍后重试";
errorText.textContent = err && err.name === "AbortError"
? "登录请求超时,请检查网络后重试"
: "连接服务器失败,请稍后重试";
errorText.style.display = "block";
submitBtn.disabled = false;
submitBtn.querySelector("span").textContent = "登录";
@@ -2763,8 +2786,6 @@ INDEX_HTML = r"""<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AimiliVPN 节点池管理系统</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg-dark: #0b0f19;
--bg-surface: rgba(22, 30, 49, 0.94);
@@ -2788,7 +2809,7 @@ INDEX_HTML = r"""<!doctype html>
body {
margin: 0;
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-dark);
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
@@ -3094,6 +3115,8 @@ INDEX_HTML = r"""<!doctype html>
top: 50%;
transform: translateY(-50%);
width: 38px;
height: auto;
min-height: 150px;
background: var(--primary-gradient);
border: 1px solid var(--border-color-hover);
border-right: none;
@@ -3453,6 +3476,7 @@ INDEX_HTML = r"""<!doctype html>
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
@@ -3666,6 +3690,7 @@ INDEX_HTML = r"""<!doctype html>
flex-direction: column;
align-items: flex-start;
padding: 16px 20px;
position: static;
}
.btn-group {
width: 100%;
@@ -3703,6 +3728,14 @@ INDEX_HTML = r"""<!doctype html>
.active-card button {
width: 100%;
}
button, .btn-telegram {
min-height: 44px;
}
.input-field,
.toolbar select,
.toolbar > input {
font-size: 16px;
}
}
/* Admin dropdown styles */
@@ -3806,11 +3839,13 @@ INDEX_HTML = r"""<!doctype html>
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
right: 0;
bottom: 0;
overflow-y: auto;
padding: 24px;
box-sizing: border-box;
background-color: rgba(9, 13, 22, 0.92);
align-items: center;
align-items: flex-start;
justify-content: center;
}
.modal-content {
@@ -3823,6 +3858,10 @@ INDEX_HTML = r"""<!doctype html>
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
position: relative;
box-sizing: border-box;
margin: auto;
max-height: calc(100vh - 48px);
max-height: calc(100dvh - 48px);
overflow-y: auto;
animation: modalFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalFadeIn {
@@ -3882,11 +3921,16 @@ INDEX_HTML = r"""<!doctype html>
}
.option-card {
width: 100%;
height: auto;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
color: var(--text-primary);
font-family: inherit;
text-align: left;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
position: relative;
@@ -3935,7 +3979,7 @@ INDEX_HTML = r"""<!doctype html>
<svg xmlns="http://www.w3.org/2000/svg" style="width:24px; height:24px; color:#818cf8;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
AimiliVPN 节点管理系统
</h1>
<div id="status" class="status" style="display: none;"><span class="status-dot"></span>服务加载中...</div>
<div id="status" class="status" role="status" aria-live="polite"><span class="status-dot"></span>服务加载中...</div>
</div>
<div class="btn-group">
@@ -3959,7 +4003,7 @@ INDEX_HTML = r"""<!doctype html>
<div id="update_check_status" class="update-check-status" role="status" aria-live="polite">点击检测更新查询 GitHub 最新正式版</div>
</div>
</div>
<a href="https://t.me/arestemple" target="_blank" class="btn-telegram">
<a href="https://t.me/arestemple" target="_blank" rel="noopener noreferrer" class="btn-telegram">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8.287 5.906c-.778.324-2.334.994-4.666 2.01-.378.15-.577.298-.595.442-.03.243.275.339.69.47l.175.055c.408.133.958.288 1.243.294.26.006.549-.1.868-.32 2.179-1.471 3.304-2.214 3.374-2.23.05-.012.12-.026.166.016.047.041.042.12.037.141-.03.129-1.227 1.241-1.846 1.817-.193.18-.33.307-.358.336-.063.065-.129.13-.19.193-.34.347-.597.609-.043.974.265.175.474.319.684.457.228.15.457.301.765.503.074.049.143.098.207.143.297.206.58.404.916.373.195-.018.398-.2.502-.754.25-1.332.74-4.22.842-5.281.01-.088.001-.22-.103-.312-.104-.092-.252-.09-.323-.087a1.52 1.52 0 0 0-.254.04z"/></svg>
Telegram
</a>
@@ -4108,20 +4152,20 @@ INDEX_HTML = r"""<!doctype html>
</div>
<!-- Credentials Modal (网页安全设置) -->
<div id="credentials_modal" class="modal">
<div class="modal-content">
<div id="credentials_modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="credentials_modal_title" aria-hidden="true">
<div class="modal-content" tabindex="-1">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h3 style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<h3 id="credentials_modal_title" style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:20px; height:20px; color: var(--primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
网页安全
</h3>
<button type="button" onclick="closeCredentialsModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<button type="button" aria-label="关闭网页安全设置" onclick="closeCredentialsModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<svg xmlns="http://www.w3.org/2000/svg" style="width:18px; height:18px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div id="credentials_error" style="color: var(--danger); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.2); border-radius: 6px; display: none;"></div>
<div id="credentials_success" style="color: var(--success); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.2); border-radius: 6px; display: none;"></div>
<div id="credentials_error" role="alert" style="color: var(--danger); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.2); border-radius: 6px; display: none;"></div>
<div id="credentials_success" role="status" aria-live="polite" style="color: var(--success); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.2); border-radius: 6px; display: none;"></div>
<form id="credentials_form" onsubmit="saveCredentials(event)">
<div class="form-group" style="margin-bottom: 12px;">
@@ -4153,20 +4197,20 @@ INDEX_HTML = r"""<!doctype html>
</div>
<!-- Network Modal (代理及网络设置包括出站路由) -->
<div id="network_modal" class="modal">
<div class="modal-content" style="max-width: 480px;">
<div id="network_modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="network_modal_title" aria-hidden="true">
<div class="modal-content" tabindex="-1" style="max-width: 480px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h3 style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<h3 id="network_modal_title" style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:20px; height:20px; color: var(--primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
代理设置
</h3>
<button type="button" onclick="closeNetworkModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<button type="button" aria-label="关闭代理设置" onclick="closeNetworkModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<svg xmlns="http://www.w3.org/2000/svg" style="width:18px; height:18px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div id="network_error" style="color: var(--danger); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.2); border-radius: 6px; display: none;"></div>
<div id="network_success" style="color: var(--success); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.2); border-radius: 6px; display: none;"></div>
<div id="network_error" role="alert" style="color: var(--danger); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.2); border-radius: 6px; display: none;"></div>
<div id="network_success" role="status" aria-live="polite" style="color: var(--success); font-size: 13px; margin-bottom: 16px; padding: 8px 12px; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.2); border-radius: 6px; display: none;"></div>
<form id="network_form" onsubmit="saveNetwork(event)">
<div class="form-group" style="margin-bottom: 16px;">
@@ -4179,18 +4223,18 @@ INDEX_HTML = r"""<!doctype html>
<label class="form-label">IP 出站路由模式</label>
<input type="hidden" id="net_routing_mode" value="auto">
<div class="option-group" id="routing_mode_group">
<div class="option-card active" data-value="auto" onclick="setRoutingMode('auto')">
<button type="button" class="option-card active" data-value="auto" aria-pressed="true" onclick="setRoutingMode('auto')">
<div class="option-card-title">自动配置</div>
<div class="option-card-desc">智能切换最稳定</div>
</div>
<div class="option-card" data-value="fixed_ip" onclick="setRoutingMode('fixed_ip')">
</button>
<button type="button" class="option-card" data-value="fixed_ip" aria-pressed="false" onclick="setRoutingMode('fixed_ip')">
<div class="option-card-title">固定 IP</div>
<div class="option-card-desc">锁定IP不自动切换</div>
</div>
<div class="option-card" data-value="fixed_region" onclick="setRoutingMode('fixed_region')">
</button>
<button type="button" class="option-card" data-value="fixed_region" aria-pressed="false" onclick="setRoutingMode('fixed_region')">
<div class="option-card-title">固定地区</div>
<div class="option-card-desc">锁定特定国家地区</div>
</div>
</button>
</div>
</div>
@@ -4205,18 +4249,18 @@ INDEX_HTML = r"""<!doctype html>
<label class="form-label">IP 出站类型过滤</label>
<input type="hidden" id="net_routing_ip_type" value="all">
<div class="option-group" id="routing_ip_type_group">
<div class="option-card active" data-value="all" onclick="setRoutingIpType('all')">
<button type="button" class="option-card active" data-value="all" aria-pressed="true" onclick="setRoutingIpType('all')">
<div class="option-card-title">所有IP</div>
<div class="option-card-desc">机房 + 住宅</div>
</div>
<div class="option-card" data-value="residential" onclick="setRoutingIpType('residential')">
</button>
<button type="button" class="option-card" data-value="residential" aria-pressed="false" onclick="setRoutingIpType('residential')">
<div class="option-card-title">住宅IP</div>
<div class="option-card-desc">静态家宽</div>
</div>
<div class="option-card" data-value="hosting" onclick="setRoutingIpType('hosting')">
</button>
<button type="button" class="option-card" data-value="hosting" aria-pressed="false" onclick="setRoutingIpType('hosting')">
<div class="option-card-title">机房IP</div>
<div class="option-card-desc">普通机房</div>
</div>
</button>
</div>
</div>
@@ -4235,14 +4279,14 @@ INDEX_HTML = r"""<!doctype html>
<!-- VPS 购买推荐 Modal -->
<div id="vps_recommend_modal" class="modal">
<div class="modal-content" style="max-width: 640px;">
<div id="vps_recommend_modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="vps_modal_title" aria-hidden="true">
<div class="modal-content" tabindex="-1" style="max-width: 640px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h3 style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<h3 id="vps_modal_title" style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:20px; height:20px; color: var(--warning);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364.364l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
VPS 购买推荐
</h3>
<button type="button" onclick="closeVpsModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<button type="button" aria-label="关闭 VPS 推荐" onclick="closeVpsModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<svg xmlns="http://www.w3.org/2000/svg" style="width:18px; height:18px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
@@ -4251,17 +4295,17 @@ INDEX_HTML = r"""<!doctype html>
<div class="vps-item">
<span class="vps-tag tag-normal">RNVPS (RackNerd) 推荐</span>
<span class="vps-desc">超低折扣价格性价比极高日常使用实惠方便海外多机房可选非常适合普通大众用户</span>
<a href="https://my.racknerd.com/aff.php?aff=18708" target="_blank" class="vps-btn">点击进入官网</a>
<a href="https://my.racknerd.com/aff.php?aff=18708" target="_blank" rel="noopener noreferrer" class="vps-btn">点击进入官网</a>
</div>
<div class="vps-item">
<span class="vps-tag tag-premium">搬瓦工 (Bandwagon) 推荐</span>
<span class="vps-desc">直连三网顶级专线经典高带宽 CN2 GIA/9929 优化线路极致速度且超凡稳定高端用户首选</span>
<a href="https://bandwagonhost.com/aff.php?aff=81790" target="_blank" class="vps-btn">点击进入官网</a>
<a href="https://bandwagonhost.com/aff.php?aff=81790" target="_blank" rel="noopener noreferrer" class="vps-btn">点击进入官网</a>
</div>
</div>
<div class="vps-footer" style="margin-top: 20px;">
官方技术支持及优质资源交流论坛<a href="https://339936.xyz" target="_blank" class="forum-link">339936.xyz</a>
官方技术支持及优质资源交流论坛<a href="https://339936.xyz" target="_blank" rel="noopener noreferrer" class="forum-link">339936.xyz</a>
</div>
<div class="vps-footer" style="margin-top: 16px; border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px; text-align: left; font-size: 13px; color: var(--text-secondary); line-height: 1.6;">
@@ -4277,17 +4321,17 @@ INDEX_HTML = r"""<!doctype html>
</div>
</div>
<div class="vps-recommend-tab" onclick="openVpsModal()">VPS购买推荐</div>
<button type="button" class="vps-recommend-tab" onclick="openVpsModal()">VPS购买推荐</button>
<!-- Gateway Modal (网关自检与代理测试) -->
<div id="gateway_modal" class="modal">
<div class="modal-content" style="max-width: 600px; width: 90%;">
<div id="gateway_modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gateway_modal_title" aria-hidden="true">
<div class="modal-content" tabindex="-1" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<h3 id="gateway_modal_title" style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:20px; height:20px; color: var(--primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
网关设置与自检
</h3>
<button type="button" onclick="closeGatewayModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<button type="button" aria-label="关闭网关设置" onclick="closeGatewayModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<svg xmlns="http://www.w3.org/2000/svg" style="width:18px; height:18px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
@@ -4340,10 +4384,10 @@ INDEX_HTML = r"""<!doctype html>
</div>
<!-- Logs Modal (日志监控与分类筛选) -->
<div id="logs_modal" class="modal">
<div class="modal-content" style="max-width: 800px; width: 95%;">
<div id="logs_modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="logs_modal_title" aria-hidden="true">
<div class="modal-content" tabindex="-1" style="max-width: 800px; width: 95%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px;">
<h3 style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<h3 id="logs_modal_title" style="margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:20px; height:20px; color: var(--primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
今日运行日志
</h3>
@@ -4358,7 +4402,7 @@ INDEX_HTML = r"""<!doctype html>
</select>
</div>
<button type="button" onclick="closeLogsModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<button type="button" aria-label="关闭日志" onclick="closeLogsModal()" style="background: transparent; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'">
<svg xmlns="http://www.w3.org/2000/svg" style="width:18px; height:18px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
@@ -4388,6 +4432,8 @@ INDEX_HTML = r"""<!doctype html>
</main>
<script>
let nodes=[], state={}, testingNodeIds = new Set();
const favoriteRequestIds = new Set();
let disconnectInFlight = false;
let currentPage = 1;
const pageSize = 50;
let currentPageNodes = [];
@@ -4398,6 +4444,101 @@ let countryFilterSignature = "";
const $=id=>document.getElementById(id);
const esc=s=>String(s||"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[c]));
function fetchWithTimeout(resource, options = {}, timeoutMs = 20000) {
if (typeof AbortController === "undefined") return fetch(resource, options);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
return fetch(resource, Object.assign({}, options, { signal: controller.signal }))
.then(
response => { window.clearTimeout(timeoutId); return response; },
error => { window.clearTimeout(timeoutId); throw error; }
);
}
async function readJsonResponse(response, fallbackMessage) {
let data = {};
try {
data = await response.json();
} catch (error) {
if (response.ok) throw new Error("服务器返回了无效数据");
}
if (!response.ok) throw new Error(data.error || `${fallbackMessage} (${response.status})`);
return data;
}
function formatUrlHost(hostname) {
const host = String(hostname || "");
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
let activeModalId = "";
let modalReturnFocus = null;
let previousBodyOverflow = "";
function showModal(id, preferredFocusSelector) {
const modal = $(id);
if (!modal) return;
modalReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
previousBodyOverflow = document.body.style.overflow;
activeModalId = id;
modal.style.display = "flex";
modal.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
window.setTimeout(() => {
const preferred = preferredFocusSelector ? modal.querySelector(preferredFocusSelector) : null;
const first = preferred || modal.querySelector('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])');
const focusTarget = first || modal.querySelector(".modal-content");
if (focusTarget) focusTarget.focus();
}, 0);
}
function hideModal(id) {
const modal = $(id);
if (!modal) return;
modal.style.display = "none";
modal.setAttribute("aria-hidden", "true");
if (activeModalId === id) {
activeModalId = "";
document.body.style.overflow = previousBodyOverflow;
const returnTarget = modalReturnFocus;
modalReturnFocus = null;
if (returnTarget && document.contains(returnTarget)) returnTarget.focus();
}
}
function closeActiveModal() {
if (activeModalId === "credentials_modal") closeCredentialsModal();
else if (activeModalId === "network_modal") closeNetworkModal();
else if (activeModalId === "vps_recommend_modal") closeVpsModal();
else if (activeModalId === "gateway_modal") closeGatewayModal();
else if (activeModalId === "logs_modal") closeLogsModal();
}
document.querySelectorAll(".modal").forEach(modal => {
modal.addEventListener("mousedown", event => {
if (event.target === modal && activeModalId === modal.id) closeActiveModal();
});
});
document.addEventListener("keydown", event => {
if (!activeModalId) return;
const modal = $(activeModalId);
if (!modal) return;
if (event.key === "Escape") {
event.preventDefault();
closeActiveModal();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(modal.querySelectorAll('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
if (!focusable.length) {
event.preventDefault();
modal.querySelector(".modal-content").focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
const renderedHtmlCache = new WeakMap();
function setHtmlIfChanged(element, html) {
if (!element || renderedHtmlCache.get(element) === html) return false;
@@ -4736,7 +4877,7 @@ function render(){
</div>
</div>
</div>
<button class="btn-danger" style="height: 38px; padding: 0 16px; border-radius: 8px;" onclick="disconnectNode()">
<button class="btn-danger" ${disconnectInFlight ? "disabled" : ""} style="height: 38px; padding: 0 16px; border-radius: 8px;" onclick="disconnectNode()">
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px; height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
断开连接
</button>
@@ -4770,9 +4911,9 @@ function render(){
if ($("active")) $("active").textContent = activeNode ? 1 : 0;
const statusMessage = state.last_check_message || "";
const activeNodeInfo = activeNode ? `<span class="badge available" style="margin-left:8px; padding:2px 8px;">${esc(translateCountry(activeNode.country))} (${activeNode.id})</span>` : `<span class="badge unavailable" style="margin-left:8px; padding:2px 8px;"></span>`;
const activeNodeInfo = activeNode ? `<span class="badge available" style="margin-left:8px; padding:2px 8px;">${esc(translateCountry(activeNode.country))} (${esc(activeNode.id)})</span>` : `<span class="badge unavailable" style="margin-left:8px; padding:2px 8px;"></span>`;
const localProxy = state.local_proxy || `http://127.0.0.1:${state.proxy_port || 7928}`;
if ($("status")) { $("status").innerHTML=`<span class="status-dot"></span>HTTP 代理本地接口${localProxy} | 活动节点${activeNodeInfo} | 状态${statusMessage}`; }
if ($("status")) { $("status").innerHTML=`<span class="status-dot"></span>HTTP 代理本地接口${esc(localProxy)} | 活动节点${activeNodeInfo} | 状态${esc(statusMessage)}`; }
// Update proxy test status card based on background checks
const pBadge = $("proxy_status_badge");
@@ -4865,9 +5006,10 @@ function render(){
const favoriteIds = Array.isArray(state.favorite_node_ids) ? state.favorite_node_ids : [];
const isFav = favoriteIds.includes(n.id);
const favoriteBusy = favoriteRequestIds.has(n.id);
const favBtn = isFav
? `<button class="test-btn" style="color: var(--warning); border-color: rgba(245, 158, 11, 0.4); padding: 0 8px; height: 30px;" onclick="toggleFavorite('${esc(n.id)}', event)"> 已收藏</button>`
: `<button class="test-btn" style="color: var(--text-secondary); border-color: var(--border-color); padding: 0 8px; height: 30px;" onclick="toggleFavorite('${esc(n.id)}', event)"> 收藏</button>`;
? `<button class="test-btn" ${favoriteBusy ? "disabled" : ""} style="color: var(--warning); border-color: rgba(245, 158, 11, 0.4); padding: 0 8px; height: 30px;" onclick="toggleFavorite('${esc(n.id)}', event)">${favoriteBusy ? "处理中" : "★ 已收藏"}</button>`
: `<button class="test-btn" ${favoriteBusy ? "disabled" : ""} style="color: var(--text-secondary); border-color: var(--border-color); padding: 0 8px; height: 30px;" onclick="toggleFavorite('${esc(n.id)}', event)">${favoriteBusy ? "处理中" : "☆ 收藏"}</button>`;
return `<tr ${rowClass}>
<td><span class="badge ${badgeClass}">${badgeText}</span></td>
@@ -4918,16 +5060,17 @@ $("btn_last_page").onclick = () => {
async function testNode(btn, id, event){
if (event) event.stopPropagation();
if (testingNodeIds.has(id)) return;
testingNodeIds.add(id);
render();
try {
const response = await fetch("./api/test_node", {
const response = await fetchWithTimeout("./api/test_node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id })
});
const result = await response.json();
}, 45000);
const result = await readJsonResponse(response, "节点检测失败");
if (result.ok && result.node) {
const idx = nodes.findIndex(n => n && n.id === id);
if (idx !== -1) {
@@ -4935,6 +5078,7 @@ async function testNode(btn, id, event){
}
}
} catch (e) {
alert("节点检测失败: " + (e.message || "未知错误"));
} finally {
testingNodeIds.delete(id);
render();
@@ -4943,19 +5087,26 @@ async function testNode(btn, id, event){
async function toggleFavorite(id, event) {
if (event) event.stopPropagation();
if (favoriteRequestIds.has(id)) return;
favoriteRequestIds.add(id);
render();
try {
const response = await fetch("./api/toggle_favorite", {
const response = await fetchWithTimeout("./api/toggle_favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id })
});
const result = await response.json();
}, 20000);
const result = await readJsonResponse(response, "切换收藏失败");
if (result.ok) {
state.favorite_node_ids = Array.isArray(result.favorite_node_ids) ? result.favorite_node_ids : [];
render();
}
} catch (e) {
console.error("切换收藏失败", e);
alert("切换收藏失败: " + (e.message || "未知错误"));
} finally {
favoriteRequestIds.delete(id);
render();
}
}
@@ -4968,7 +5119,7 @@ let nodesRequestPromise = null;
async function fetchNodesSnapshot() {
if (nodesRequestPromise) return nodesRequestPromise;
const request = (async () => {
const response = await fetch("./api/nodes", { cache: "no-store" });
const response = await fetchWithTimeout("./api/nodes", { cache: "no-store" }, 20000);
if (!response.ok) throw new Error(`节点状态请求失败 (${response.status})`);
return response.json();
})();
@@ -5040,7 +5191,7 @@ function startConnectionPolling() {
clearInterval(pollInterval);
pollInterval = null;
try {
await fetch("./api/test_proxy", { method: "POST" });
await fetchWithTimeout("./api/test_proxy", { method: "POST" }, 45000);
} catch(pe){}
load();
}
@@ -5061,15 +5212,15 @@ async function connectNode(id){
state.last_check_message = "正在发送连接请求...";
render();
startConnectionPolling();
try {
const r = await fetch("./api/connect",{
const request = fetchWithTimeout("./api/connect",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id})
});
const result = await r.json();
}, 180000);
startConnectionPolling();
const r = await request;
const result = await readJsonResponse(r, "连接请求失败");
if (!result.ok) {
if (!result.cancelled) {
alert("连接失败: " + (result.error || "未知错误"));
@@ -5084,32 +5235,42 @@ async function connectNode(id){
return;
}
} catch(e) {
alert("连接请求错误");
alert("连接请求错误: " + (e.message || "未知错误"));
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
state.is_connecting = false;
state.pending_node_id = "";
render();
try {
await load();
} catch (loadError) {
render();
}
}
}
async function disconnectNode(){
if (disconnectInFlight) return;
if (!confirm("确定要断开当前的 VPN 连接吗?")) return;
disconnectInFlight = true;
render();
try {
const response = await fetch("./api/disconnect", { method: "POST" });
const result = await response.json();
const response = await fetchWithTimeout("./api/disconnect", { method: "POST" }, 60000);
const result = await readJsonResponse(response, "断开连接失败");
if (result.ok) {
try {
await fetch("./api/test_proxy", { method: "POST" });
await fetchWithTimeout("./api/test_proxy", { method: "POST" }, 45000);
} catch(pe){}
load();
} else {
alert("断开连接失败: " + (result.error || "未知错误"));
}
} catch (e) {
alert("请求断开连接失败");
alert("请求断开连接失败: " + (e.message || "未知错误"));
} finally {
disconnectInFlight = false;
render();
}
}
@@ -5147,14 +5308,14 @@ $("status_filter").onchange=()=>{ currentPage = 1; render(); };
$("refresh").onclick=async()=>{
refreshButtonBusy("正在启动更新...");
try{
const response = await fetch("./api/refresh_nodes",{
const response = await fetchWithTimeout("./api/refresh_nodes",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
discovery_countries: Array.from(selectedDiscoveryCountries).sort()
})
});
const result = await response.json();
}, 25000);
const result = await readJsonResponse(response, "节点更新启动失败");
if (!response.ok || !result.ok) {
throw new Error(result.error || "节点更新启动失败");
}
@@ -5184,8 +5345,8 @@ $("btn_test_proxy").onclick = async () => {
latVal.textContent = "";
try {
const response = await fetch("./api/test_proxy", { method: "POST" });
const result = await response.json();
const response = await fetchWithTimeout("./api/test_proxy", { method: "POST" }, 45000);
const result = await readJsonResponse(response, "代理检测失败");
if (result.ok) {
badge.className = "badge available";
badge.textContent = "可用";
@@ -5227,8 +5388,8 @@ async function checkForUpdate(event) {
statusBox.className = "update-check-status";
statusBox.textContent = "正在连接 GitHub 检查最新正式版...";
try {
const response = await fetch("./api/check_update", { cache: "no-store" });
const result = await response.json();
const response = await fetchWithTimeout("./api/check_update", { cache: "no-store" }, 25000);
const result = await readJsonResponse(response, "更新检查失败");
if (!response.ok || !result.ok) {
throw new Error(result.error || "更新检查失败");
}
@@ -5348,7 +5509,7 @@ async function toggleFavRouting() {
updateFavPanelUI();
try {
const res = await fetch("./api/update_routing", {
const res = await fetchWithTimeout("./api/update_routing", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -5356,8 +5517,8 @@ async function toggleFavRouting() {
force_country: state.force_country || "",
routing_ip_type: state.routing_ip_type || "all"
})
});
const data = await res.json();
}, 25000);
const data = await readJsonResponse(res, "更新出站路由设置失败");
if (res.ok && data.ok) {
load();
} else {
@@ -5377,7 +5538,9 @@ function selectOptionCard(groupName, value) {
const cards = document.querySelectorAll("#routing_mode_group .option-card");
cards.forEach(card => {
if (card.getAttribute("data-value") === value) {
const selected = card.getAttribute("data-value") === value;
card.setAttribute("aria-pressed", selected ? "true" : "false");
if (selected) {
card.classList.add("active");
} else {
card.classList.remove("active");
@@ -5391,7 +5554,9 @@ function selectOptionCard(groupName, value) {
const cards = document.querySelectorAll("#routing_ip_type_group .option-card");
cards.forEach(card => {
if (card.getAttribute("data-value") === value) {
const selected = card.getAttribute("data-value") === value;
card.setAttribute("aria-pressed", selected ? "true" : "false");
if (selected) {
card.classList.add("active");
} else {
card.classList.remove("active");
@@ -5429,7 +5594,7 @@ function handleRoutingModeChange(mode) {
warningDiv.style.color = "var(--warning)";
warningDiv.style.background = "rgba(245, 158, 11, 0.1)";
warningDiv.style.border = "1px solid rgba(245, 158, 11, 0.2)";
warningDiv.innerHTML = ` <strong>固定IP</strong>锁定当前连接的节点不管该节点是否失效系统都绝不自动切换至其他IP如果节点由于网络故障失效会造成代理中断但如果OpenVPN连接意外退出脚本将尝试为您在后台重新拉起连接同一IP<br><strong>提示</strong>您可以在主页 of 节点列表中直接点击连接按钮来选择并锁定不同的IP节点`;
warningDiv.innerHTML = ` <strong>固定IP</strong>锁定当前连接的节点不管该节点是否失效系统都绝不自动切换至其他IP如果节点由于网络故障失效会造成代理中断但如果OpenVPN连接意外退出脚本将尝试为您在后台重新拉起连接同一IP<br><strong>提示</strong>您可以在主页节点列表中直接点击连接按钮来选择并锁定不同的IP节点`;
} else {
countryGroup.style.display = "none";
warningDiv.style.color = "var(--text-secondary)";
@@ -5472,12 +5637,12 @@ function openCredentialsModal() {
$("cred_port").value = state.port || 8787;
$("cred_suffix").value = state.secret_path || "";
}
$("credentials_modal").style.display = "flex";
showModal("credentials_modal", "#cred_username");
$("admin_dropdown").style.display = "none";
}
function closeCredentialsModal() {
$("credentials_modal").style.display = "none";
hideModal("credentials_modal");
}
async function saveCredentials(e) {
@@ -5490,7 +5655,7 @@ async function saveCredentials(e) {
successDiv.style.display = "none";
const username = $("cred_username").value.trim();
const password = $("cred_password").value.trim();
const password = $("cred_password").value;
const port = parseInt($("cred_port").value);
const suffix = $("cred_suffix").value.trim();
@@ -5522,7 +5687,7 @@ async function saveCredentials(e) {
submitBtn.textContent = "正在保存...";
try {
const res = await fetch("./api/update_credentials", {
const res = await fetchWithTimeout("./api/update_credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -5531,9 +5696,8 @@ async function saveCredentials(e) {
port: port,
secret_path: suffix
})
});
const data = await res.json();
}, 25000);
const data = await readJsonResponse(res, "保存网页安全设置失败");
if (res.ok && data.ok) {
if (data.restart_needed) {
successDiv.textContent = "保存成功!网页管理端口或路径已变更,页面将在 4 秒内自动跳转...";
@@ -5544,7 +5708,7 @@ async function saveCredentials(e) {
setTimeout(() => {
const protocol = window.location.protocol;
const host = window.location.hostname;
const host = formatUrlHost(window.location.hostname);
window.location.href = `${protocol}//${host}:${port}/${suffix}/`;
}, 4000);
} else {
@@ -5588,12 +5752,12 @@ function openNetworkModal() {
}
populateRoutingCountries();
$("network_modal").style.display = "flex";
showModal("network_modal", "#net_proxy_port");
$("admin_dropdown").style.display = "none";
}
function closeNetworkModal() {
$("network_modal").style.display = "none";
hideModal("network_modal");
}
async function saveNetwork(e) {
@@ -5637,7 +5801,7 @@ async function saveNetwork(e) {
submitBtn.textContent = "正在保存...";
try {
const res = await fetch("./api/update_settings", {
const res = await fetchWithTimeout("./api/update_settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -5646,9 +5810,8 @@ async function saveNetwork(e) {
force_country: forceCountry,
routing_ip_type: routingIpType
})
});
const data = await res.json();
}, 25000);
const data = await readJsonResponse(res, "保存代理设置失败");
if (res.ok && data.ok) {
if (data.restart_needed) {
successDiv.textContent = "保存成功!代理出站端口已变更,页面将在 4 秒内自动刷新...";
@@ -5685,22 +5848,24 @@ async function saveNetwork(e) {
function openVpsModal() {
$("vps_recommend_modal").style.display = "flex";
showModal("vps_recommend_modal");
}
function closeVpsModal() {
$("vps_recommend_modal").style.display = "none";
hideModal("vps_recommend_modal");
}
async function logoutAdmin() {
try {
const res = await fetch("./api/logout", { method: "POST" });
const res = await fetchWithTimeout("./api/logout", { method: "POST" }, 20000);
if (res.ok) {
window.location.reload();
} else {
alert("退出登录失败,请稍后重试");
}
} catch (err) {
console.error("退出登录失败", err);
window.location.reload();
alert("退出登录失败: " + (err.message || "网络错误"));
}
}
@@ -5727,14 +5892,14 @@ let gatewayRequestInFlight = false;
function openGatewayModal() {
$("admin_dropdown").style.display = "none";
$("gateway_modal").style.display = "flex";
showModal("gateway_modal", "#btn_test_proxy");
loadGatewayStatus();
if (gatewayPollInterval) clearInterval(gatewayPollInterval);
gatewayPollInterval = setInterval(loadGatewayStatus, 3000);
}
function closeGatewayModal() {
$("gateway_modal").style.display = "none";
hideModal("gateway_modal");
if (gatewayPollInterval) {
clearInterval(gatewayPollInterval);
gatewayPollInterval = null;
@@ -5745,7 +5910,7 @@ async function loadGatewayStatus() {
if (gatewayRequestInFlight || !isPageVisible() || $("gateway_modal").style.display !== "flex") return;
gatewayRequestInFlight = true;
try {
const res = await fetch("./api/gateway_status", { cache: "no-store" });
const res = await fetchWithTimeout("./api/gateway_status", { cache: "no-store" }, 20000);
if (!res.ok) throw new Error(`网关状态请求失败 (${res.status})`);
const data = await res.json();
if (data.ok && data.services) {
@@ -5793,14 +5958,14 @@ const MAX_RENDERED_LOG_LINES = 300;
function openLogsModal() {
$("admin_dropdown").style.display = "none";
$("logs_modal").style.display = "flex";
showModal("logs_modal", "#log_filter_select");
loadLogs();
if (logsPollInterval) clearInterval(logsPollInterval);
logsPollInterval = setInterval(loadLogs, 2500);
}
function closeLogsModal() {
$("logs_modal").style.display = "none";
hideModal("logs_modal");
if (logsPollInterval) {
clearInterval(logsPollInterval);
logsPollInterval = null;
@@ -5811,7 +5976,7 @@ async function loadLogs() {
if (logsRequestInFlight || !isPageVisible() || $("logs_modal").style.display !== "flex") return;
logsRequestInFlight = true;
try {
const res = await fetch("./api/logs", { cache: "no-store" });
const res = await fetchWithTimeout("./api/logs", { cache: "no-store" }, 20000);
if (!res.ok) throw new Error(`日志请求失败 (${res.status})`);
const data = await res.json();
if (Array.isArray(data.logs)) {
@@ -6182,6 +6347,7 @@ class Handler(BaseHTTPRequestHandler):
if not session_token:
return False
purge_expired_sessions()
with lock:
exp_time = active_sessions.get(session_token)
if exp_time is not None and exp_time > time.time():
@@ -6428,8 +6594,11 @@ class Handler(BaseHTTPRequestHandler):
expected_pwd = ui_cfg.get("password", "")
expected_uname = ui_cfg.get("username", "admin")
if expected_pwd and input_pwd == expected_pwd and input_uname == expected_uname:
password_matches = bool(expected_pwd) and secrets.compare_digest(input_pwd, str(expected_pwd))
username_matches = secrets.compare_digest(input_uname, str(expected_uname))
if password_matches and username_matches:
token = uuid.uuid4().hex
purge_expired_sessions()
with lock:
active_sessions[token] = time.time() + 30 * 24 * 3600
body = json.dumps({"ok": True}).encode("utf-8")
@@ -6484,7 +6653,7 @@ class Handler(BaseHTTPRequestHandler):
try:
payload = self.read_json_body()
new_username = str(payload.get("username") or "").strip()
new_password = str(payload.get("password") or "").strip()
new_password = str(payload.get("password") or "")
new_port = payload.get("port")
new_suffix = str(payload.get("secret_path") or "").strip()
@@ -6716,7 +6885,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({
"ok": True,
"message": "已在后台启动节点更新流程",
"running": False,
"running": True,
"discovery_countries": discovery_countries,
})
except ValueError as exc: