Files
aimili-vpngate/vpngate_manager.py
T

7255 lines
293 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import base64
import csv
import json
import os
import queue
import re
import secrets
import select
import shlex
import signal
import socket
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from collections import deque
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
import concurrent.futures
import sys
import uuid
class DualStackHTTPServer(ThreadingHTTPServer):
def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True):
host, port = server_address
if ":" in host or host == "":
self.address_family = socket.AF_INET6
else:
self.address_family = socket.AF_INET
try:
super().__init__(server_address, RequestHandlerClass, bind_and_activate)
except OSError as e:
if self.address_family == socket.AF_INET6:
fallback_host = "0.0.0.0" if host in ("::", "") else "127.0.0.1"
print(f"[警告] 绑定 Web 管理后台 IPv6 {host}:{port} 失败 ({e}),正在尝试回退至 IPv4 {fallback_host} ...", flush=True)
# 关闭第一次失败时可能已创建的 socket
try:
self.socket.close()
except Exception:
pass
self.address_family = socket.AF_INET
super().__init__((fallback_host, port), RequestHandlerClass, bind_and_activate)
else:
raise e
def server_bind(self):
if self.address_family == socket.AF_INET6:
try:
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
except OSError:
pass
super().server_bind()
import vpn_utils
import proxy_server
import snapshot_utils
def env_int(name: str, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
raw = os.environ.get(name)
try:
value = int(raw) if raw not in (None, "") else default
except (TypeError, ValueError):
print(f"[配置警告] 环境变量 {name}={raw!r} 不是有效整数,使用默认值 {default}", flush=True)
value = default
if min_value is not None and value < min_value:
print(f"[配置警告] 环境变量 {name}={value} 小于允许值 {min_value},使用默认值 {default}", flush=True)
return default
if max_value is not None and value > max_value:
print(f"[配置警告] 环境变量 {name}={value} 大于允许值 {max_value},使用默认值 {default}", flush=True)
return default
return value
def bounded_int(value: Any, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
if min_value is not None and parsed < min_value:
return default
if max_value is not None and parsed > max_value:
return default
return parsed
API_HTTPS_URL = os.environ.get("VPNGATE_API_HTTPS_URL", "https://www.vpngate.net/api/iphone/").strip()
API_HTTP_URL = os.environ.get("VPNGATE_API_HTTP_URL", "http://www.vpngate.net/api/iphone/").strip()
MIRROR_HTTPS_URL = os.environ.get(
"VPNGATE_MIRROR_HTTPS_URL",
"https://baoweise-bot.github.io/aimili-vpngate/vpngate.csv",
).strip()
MIRROR_HTTP_URL = os.environ.get(
"VPNGATE_MIRROR_HTTP_URL",
"http://baoweise-bot.github.io/aimili-vpngate/vpngate.csv",
).strip()
# Kept as the primary URL for diagnostics and backwards-compatible state output.
API_URL = API_HTTPS_URL
FETCH_INTERVAL_SECONDS = env_int("FETCH_INTERVAL_SECONDS", 1260, 1)
CHECK_INTERVAL_SECONDS = env_int("CHECK_INTERVAL_SECONDS", 1260, 1)
TARGET_VALID_NODES = env_int("TARGET_VALID_NODES", 3, 1)
MAX_SCAN_ROWS = env_int("MAX_SCAN_ROWS", 300, 1)
API_FETCH_TIMEOUT_SECONDS = env_int("API_FETCH_TIMEOUT_SECONDS", 10, 1, 60)
API_SOURCE_DEADLINE_SECONDS = env_int("API_SOURCE_DEADLINE_SECONDS", 6, 2, 30)
OPENVPN_TEST_TIMEOUT_SECONDS = env_int("OPENVPN_TEST_TIMEOUT_SECONDS", 35, 1)
MANUAL_TEST_NODE_LIMIT = env_int("MANUAL_TEST_NODE_LIMIT", 5, 1, 20)
INITIAL_CONNECT_TEST_LIMIT = env_int("INITIAL_CONNECT_TEST_LIMIT", 10, 1, 50)
NODE_PROBE_WORKERS = env_int("NODE_PROBE_WORKERS", 5, 1, 20)
PROXY_FAILURE_THRESHOLD = env_int("PROXY_FAILURE_THRESHOLD", 3, 1, 10)
SWITCH_PREFLIGHT_MAX_AGE_SECONDS = env_int("SWITCH_PREFLIGHT_MAX_AGE_SECONDS", 180, 0, 3600)
OPENVPN_CMD = os.environ.get("OPENVPN_CMD", "openvpn")
OPENVPN_AUTH_USER = os.environ.get("OPENVPN_AUTH_USER", "vpn")
OPENVPN_AUTH_PASS = os.environ.get("OPENVPN_AUTH_PASS", "vpn")
LOCAL_PROXY_HOST = os.environ.get("LOCAL_PROXY_HOST", "127.0.0.1")
LOCAL_PROXY_PORT = env_int("LOCAL_PROXY_PORT", 7928, 1, 65535)
UI_HOST = os.environ.get("UI_HOST", "::")
UI_PORT = env_int("UI_PORT", 8787, 1, 65535)
INVALID_BACKOFF_SECONDS = env_int("INVALID_BACKOFF_SECONDS", 30 * 60, 1)
DEPLOYMENT_MODE = os.environ.get("DEPLOYMENT_MODE", "source").strip().lower()
if DEPLOYMENT_MODE not in {"source", "docker"}:
DEPLOYMENT_MODE = "source"
DEPLOYMENT_MODE_LABEL = "Docker 容器" if DEPLOYMENT_MODE == "docker" else "Python 源码"
UPDATE_COMMAND = (
"docker compose pull && docker compose up -d"
if DEPLOYMENT_MODE == "docker"
else "ml update"
)
ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent
DEFAULT_APP_VERSION = "2.1.2"
try:
_version_text = (ROOT_DIR / "VERSION").read_text(encoding="utf-8").strip()
except OSError:
_version_text = DEFAULT_APP_VERSION
APP_VERSION = _version_text if re.fullmatch(r"\d+\.\d+(?:\.\d+)?", _version_text) else DEFAULT_APP_VERSION
APP_VERSION_LABEL = f"V{APP_VERSION} 正式版"
GITHUB_REPOSITORY = "baoweise-bot/aimili-vpngate"
GITHUB_REPOSITORY_URL = f"https://github.com/{GITHUB_REPOSITORY}"
GITHUB_MAIN_BRANCH_URL = f"{GITHUB_REPOSITORY_URL}/tree/main"
GITHUB_LATEST_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest"
DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPNGATE_DATA_DIR") else ROOT_DIR / "vpngate_data"
CONFIG_DIR = DATA_DIR / "configs"
NODES_FILE = DATA_DIR / "nodes.json"
STATE_FILE = DATA_DIR / "state.json"
AUTH_FILE = DATA_DIR / "vpngate_auth.txt"
UPSTREAM_PROXY_AUTH_FILE = DATA_DIR / "upstream_proxy_auth.txt"
BLACKLIST_FILE = DATA_DIR / "blacklist.json"
API_CACHE_FILE = DATA_DIR / "api_snapshot.csv"
API_CACHE_META_FILE = DATA_DIR / "api_snapshot.meta.json"
BUNDLED_SNAPSHOT_FILE = ROOT_DIR / "mirror" / "vpngate.csv"
WEB_LOG_MAX_ENTRIES = 500
lock = threading.RLock()
maintenance_lock = threading.Lock()
connection_attempt_lock = threading.Lock()
background_refill_lock = threading.Lock()
background_refill_cancel_event = threading.Event()
background_refill_thread: threading.Thread | None = None
active_sessions: dict[str, float] = {}
active_openvpn_process: subprocess.Popen[str] | None = None
pending_openvpn_process: subprocess.Popen[str] | None = None
active_connection_cancel_event: threading.Event | None = None
connection_epoch = 0
active_openvpn_node_id = ""
is_connecting = False
last_active_ping_time = 0.0
last_active_latency = 0
consecutive_proxy_failures = 0
last_proxy_failure_node_id = ""
last_collector_heartbeat = 0.0
last_checker_heartbeat = 0.0
last_pinger_heartbeat = 0.0
server_start_time = time.time()
ip_enrichment_wakeup = threading.Event()
IP_ENRICHMENT_FIELDS = (
"owner",
"asn",
"as_name",
"location",
"ip_type",
"quality",
"is_proxy",
"is_hosting",
"is_mobile",
"ip_type_reason",
)
class ConnectionCancelled(RuntimeError):
pass
class SourceDeadlineExceeded(TimeoutError):
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)
if not AUTH_FILE.exists():
AUTH_FILE.write_text(f"{OPENVPN_AUTH_USER}\n{OPENVPN_AUTH_PASS}\n", encoding="utf-8")
try:
AUTH_FILE.chmod(0o600)
except OSError:
pass
def upstream_proxy_auth_file() -> str | None:
username, password = vpn_utils.get_upstream_proxy_auth()
if username is None:
return None
try:
DATA_DIR.mkdir(exist_ok=True, parents=True)
UPSTREAM_PROXY_AUTH_FILE.write_text(f"{username}\n{password or ''}\n", encoding="utf-8")
try:
UPSTREAM_PROXY_AUTH_FILE.chmod(0o600)
except OSError:
pass
return str(UPSTREAM_PROXY_AUTH_FILE)
except Exception as exc:
print(f"[上游代理认证] 写入认证文件失败: {exc}", flush=True)
return None
def write_json(path: Path, data: Any) -> None:
with lock:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def read_json(path: Path, default: Any) -> Any:
with lock:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default
import hashlib
def generate_random_password() -> str:
import string
chars = string.ascii_letters + string.digits
while True:
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)
has_digit = any(c.isdigit() for c in pwd)
if has_lower and has_upper and has_digit:
return pwd
def generate_random_username() -> str:
import string
chars = string.ascii_letters + string.digits
while True:
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)
has_upper = any(c.isupper() for c in uname)
has_digit = any(c.isdigit() for c in uname)
if has_lower and has_upper and has_digit:
return uname
def normalize_discovery_countries(value: Any) -> list[str]:
if not isinstance(value, (list, tuple, set)):
return []
normalized: list[str] = []
seen: set[str] = set()
for item in value:
code = str(item or "").strip().upper()
if not re.fullmatch(r"[A-Z]{2}", code) or code in seen:
continue
normalized.append(code)
seen.add(code)
if len(normalized) >= 250:
break
return normalized
def load_ui_config() -> dict[str, Any]:
with lock:
auth_file = DATA_DIR / "ui_auth.json"
config = {
"username": "",
"secret_path": "EJsW2EeBo9lY",
"password": "",
"host": UI_HOST,
"port": UI_PORT,
"proxy_port": LOCAL_PROXY_PORT,
"routing_mode": "auto",
"force_country": "",
"routing_ip_type": "all",
"connection_enabled": True,
"fixed_node_id": "",
"favorite_node_ids": [],
"fav_fail_fallback": False,
"discovery_countries": [],
}
updated = False
if auth_file.exists():
try:
data = json.loads(auth_file.read_text(encoding="utf-8"))
for key, val in data.items():
config[key] = val
for key in ["host", "port", "proxy_port", "routing_mode", "force_country", "routing_ip_type", "connection_enabled", "fixed_node_id", "favorite_node_ids", "fav_fail_fallback", "discovery_countries"]:
if key not in data:
updated = True
except Exception:
pass
if not config.get("username"):
config["username"] = generate_random_username()
updated = True
if not config.get("password"):
config["password"] = generate_random_password()
updated = True
normalized_port = bounded_int(config.get("port"), UI_PORT, 1, 65535)
if normalized_port != config.get("port"):
config["port"] = normalized_port
updated = True
normalized_proxy_port = bounded_int(config.get("proxy_port"), LOCAL_PROXY_PORT, 1024, 65535)
if normalized_proxy_port == normalized_port:
fallback_proxy_port = LOCAL_PROXY_PORT if LOCAL_PROXY_PORT != normalized_port else 7928
if fallback_proxy_port == normalized_port:
fallback_proxy_port = 7929
normalized_proxy_port = fallback_proxy_port
if normalized_proxy_port != config.get("proxy_port"):
config["proxy_port"] = normalized_proxy_port
updated = True
normalized_discovery_countries = normalize_discovery_countries(config.get("discovery_countries"))
if normalized_discovery_countries != config.get("discovery_countries"):
config["discovery_countries"] = normalized_discovery_countries
updated = True
if not auth_file.exists() or updated:
try:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, config)
except Exception:
pass
return config
def persist_discovery_countries(value: Any) -> list[str]:
if not isinstance(value, list):
raise ValueError("国家筛选范围必须是国家代码列表")
countries = normalize_discovery_countries(value)
ui_cfg = load_ui_config()
ui_cfg["discovery_countries"] = countries
auth_file = DATA_DIR / "ui_auth.json"
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
return countries
# 初始化时优先从 ui_auth.json 加载保存的代理出站端口和网页端口配置以覆盖环境变量
try:
_init_cfg = load_ui_config()
if "proxy_port" in _init_cfg:
LOCAL_PROXY_PORT = bounded_int(_init_cfg["proxy_port"], LOCAL_PROXY_PORT, 1024, 65535)
if "port" in _init_cfg:
UI_PORT = bounded_int(_init_cfg["port"], UI_PORT, 1, 65535)
if "host" in _init_cfg:
UI_HOST = _init_cfg["host"]
except Exception:
pass
def get_session_token(password: str, username: str = "admin") -> str:
salt = "aimilivpn_secure_salt_2026"
return hashlib.sha256((username + ":" + password + salt).encode("utf-8")).hexdigest()
_last_cleanup_time = 0.0
def cleanup_old_logs(logs_dir: Path) -> None:
global _last_cleanup_time
now = time.time()
with lock:
if now - _last_cleanup_time < 3600:
return
_last_cleanup_time = now
try:
three_days_sec = 3 * 24 * 60 * 60
for path in logs_dir.glob("*.json"):
match = re.match(r"^(\d{4}-\d{2}-\d{2})\.json$", path.name)
if match:
date_str = match.group(1)
try:
file_time = time.mktime(time.strptime(date_str, "%Y-%m-%d"))
today_str = time.strftime("%Y-%m-%d", time.localtime())
today_time = time.mktime(time.strptime(today_str, "%Y-%m-%d"))
if today_time - file_time >= three_days_sec:
with lock:
path.unlink()
print(f"[清理] 已删除3天前的旧日志文件: {path.name}", flush=True)
except Exception:
if now - path.stat().st_mtime > three_days_sec:
with lock:
path.unlink()
except Exception as e:
print(f"[清理错误] 清理旧日志失败: {e}", flush=True)
def read_recent_log_entries(log_file: Path, limit: int = WEB_LOG_MAX_ENTRIES) -> list[dict[str, Any]]:
if limit <= 0 or not log_file.exists():
return []
entries: deque[dict[str, Any]] = deque(maxlen=limit)
with open(log_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except (TypeError, ValueError):
continue
if isinstance(entry, dict):
entries.append(entry)
return list(entries)
def log_to_json(level: str, module: str, message: str) -> None:
try:
logs_dir = DATA_DIR / "logs"
logs_dir.mkdir(exist_ok=True, parents=True)
date_str = time.strftime("%Y-%m-%d", time.localtime())
log_file = logs_dir / f"{date_str}.json"
entry = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"level": level,
"module": module,
"message": message
}
with lock:
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
cleanup_old_logs(logs_dir)
except Exception as e:
print(f"[Log Error] Failed to write JSON log: {e}", flush=True)
def set_state(**updates: Any) -> None:
# Keep the read-modify-write transaction atomic across background threads.
with lock:
state = get_state()
state.update(updates)
write_json(STATE_FILE, state)
def read_nodes() -> list[dict[str, Any]]:
raw = read_json(NODES_FILE, [])
if not isinstance(raw, list):
return []
return [item for item in raw if isinstance(item, dict)]
def get_state() -> dict[str, Any]:
global active_openvpn_node_id, is_connecting
state = read_json(STATE_FILE, {})
state.pop("password", None)
state["active_openvpn_node_id"] = active_openvpn_node_id
state["is_connecting"] = is_connecting
state["maintenance_running"] = maintenance_lock.locked()
state.setdefault("api_url", API_URL)
state.setdefault("mirror_url", MIRROR_HTTPS_URL)
state.setdefault("last_fetch_source", "")
state.setdefault("target_valid_nodes", TARGET_VALID_NODES)
state.setdefault("fetch_interval_seconds", FETCH_INTERVAL_SECONDS)
state.setdefault("check_interval_seconds", CHECK_INTERVAL_SECONDS)
_proxy_display = f"[{LOCAL_PROXY_HOST}]" if ":" in LOCAL_PROXY_HOST else LOCAL_PROXY_HOST
state["local_proxy"] = f"http://{_proxy_display}:{LOCAL_PROXY_PORT}"
state.setdefault("last_fetch_status", "not_started")
state.setdefault("last_check_message", "")
state.setdefault("pending_node_id", "")
state.setdefault("blacklisted_nodes", 0)
state["app_version"] = APP_VERSION
state["app_version_label"] = APP_VERSION_LABEL
state["deployment_mode"] = DEPLOYMENT_MODE
state["deployment_mode_label"] = DEPLOYMENT_MODE_LABEL
# Pre-populate settings inputs in UI
ui_cfg = load_ui_config()
state["username"] = ui_cfg.get("username", "admin")
state["port"] = ui_cfg.get("port", 8787)
state["secret_path"] = ui_cfg.get("secret_path", "EJsW2EeBo9lY")
state["password_set"] = bool(ui_cfg.get("password"))
state["proxy_port"] = ui_cfg.get("proxy_port", 7928)
state["routing_mode"] = ui_cfg.get("routing_mode", "auto")
state["force_country"] = ui_cfg.get("force_country", "")
state["routing_ip_type"] = ui_cfg.get("routing_ip_type", "all")
state["connection_enabled"] = ui_cfg.get("connection_enabled", True)
state["fixed_node_id"] = ui_cfg.get("fixed_node_id", "")
state["favorite_node_ids"] = ui_cfg.get("favorite_node_ids", [])
state["discovery_countries"] = normalize_discovery_countries(ui_cfg.get("discovery_countries"))
state["fav_fail_fallback"] = False
return state
def safe_name(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip())
return value.strip("._") or "node"
def clear_active_connection_state(message: str) -> None:
stop_active_openvpn()
with lock:
nodes = read_nodes()
for item in nodes:
item["active"] = False
write_json(NODES_FILE, nodes)
set_state(
active_openvpn_node_id="",
is_connecting=False,
pending_node_id="",
active_node_latency="无活动连接",
proxy_ok=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error=message,
last_check_message=message,
)
def parse_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def proxy_basic_auth_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
return f"Proxy-Authorization: Basic {token}\r\n"
def recv_exact_from_socket(sock: socket.socket, size: int) -> bytes:
data = b""
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise RuntimeError("Unexpected EOF while reading proxy response")
data += chunk
return data
def read_http_response_head(sock: socket.socket, limit: int = 65536) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if len(data) > limit:
raise RuntimeError("Proxy response header too large")
if b"\r\n\r\n" not in data:
raise RuntimeError("Incomplete HTTP proxy response header")
return data
def socks5_address_bytes(host: str) -> tuple[int, bytes]:
try:
return 1, socket.inet_aton(host)
except OSError:
pass
try:
return 4, socket.inet_pton(socket.AF_INET6, host)
except OSError:
pass
host_bytes = host.encode("idna")
if len(host_bytes) > 255:
raise RuntimeError("SOCKS5 target host name is too long")
return 3, bytes([len(host_bytes)]) + host_bytes
def read_socks5_connect_reply(sock: socket.socket) -> None:
header = recv_exact_from_socket(sock, 4)
if header[0] != 5:
raise RuntimeError("Invalid SOCKS5 reply version")
atyp = header[3]
if atyp == 1:
recv_exact_from_socket(sock, 4)
elif atyp == 3:
domain_len = recv_exact_from_socket(sock, 1)[0]
recv_exact_from_socket(sock, domain_len)
elif atyp == 4:
recv_exact_from_socket(sock, 16)
else:
raise RuntimeError(f"Invalid SOCKS5 reply address type: {atyp}")
recv_exact_from_socket(sock, 2)
if header[1] != 0:
raise RuntimeError(f"SOCKS5 connection request rejected, code={header[1]}")
def format_host_port(host: str, port: int) -> str:
return f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
def fetch_api_text_via_proxy(url: str, ptype: str, phost: str, pport: int, use_ssl_verify: bool = True) -> str:
import socket
import ssl
import urllib.parse
parsed = urllib.parse.urlsplit(url)
domain = parsed.hostname or "www.vpngate.net"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
is_https = parsed.scheme == "https"
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
is_ipv6 = ":" in phost
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = None
try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(API_FETCH_TIMEOUT_SECONDS)
s.connect((phost, pport))
proxy_user, proxy_pass = vpn_utils.get_upstream_proxy_auth()
if ptype == "socks":
# SOCKS5 Handshake
if proxy_user is not None:
s.sendall(b"\x05\x02\x00\x02")
else:
s.sendall(b"\x05\x01\x00")
resp = recv_exact_from_socket(s, 2)
if len(resp) < 2 or resp[0] != 5:
raise RuntimeError("SOCKS5 authentication failed or unsupported")
if resp[1] == 2:
if proxy_user is None:
raise RuntimeError("SOCKS5 proxy requires username/password authentication")
user_bytes = proxy_user.encode("utf-8")
pass_bytes = (proxy_pass or "").encode("utf-8")
if len(user_bytes) > 255 or len(pass_bytes) > 255:
raise RuntimeError("SOCKS5 proxy credentials are too long")
s.sendall(b"\x01" + bytes([len(user_bytes)]) + user_bytes + bytes([len(pass_bytes)]) + pass_bytes)
auth_resp = recv_exact_from_socket(s, 2)
if len(auth_resp) < 2 or auth_resp[1] != 0:
raise RuntimeError("SOCKS5 username/password authentication failed")
elif resp[1] != 0:
raise RuntimeError("SOCKS5 authentication method unsupported")
# SOCKS5 Connect
atyp, addr_bytes = socks5_address_bytes(domain)
req = b"\x05\x01\x00" + bytes([atyp]) + addr_bytes + port.to_bytes(2, 'big')
s.sendall(req)
read_socks5_connect_reply(s)
# If HTTPS, wrap socket with SSL
if is_https:
ctx = ssl.create_default_context() if use_ssl_verify else ssl._create_unverified_context()
s = ctx.wrap_socket(s, server_hostname=domain)
else: # http proxy
if is_https:
# HTTP CONNECT tunnel
authority = format_host_port(domain, port)
auth_header = proxy_basic_auth_header(proxy_user, proxy_pass or "") if proxy_user is not None else ""
req_str = f"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\nUser-Agent: Mozilla/5.0 vpngate-openvpn-manager/2.0\r\n{auth_header}Proxy-Connection: Keep-Alive\r\n\r\n"
s.sendall(req_str.encode('ascii'))
resp = read_http_response_head(s)
status_line = resp.split(b"\r\n", 1)[0].decode("utf-8", errors="replace")
status_parts = status_line.split()
status_code = int(status_parts[1]) if len(status_parts) >= 2 and status_parts[1].isdigit() else 0
if status_code != 200:
raise RuntimeError(f"HTTP CONNECT tunnel failed: {status_line}")
# Wrap socket with SSL
ctx = ssl.create_default_context() if use_ssl_verify else ssl._create_unverified_context()
s = ctx.wrap_socket(s, server_hostname=domain)
else:
# Direct HTTP request through proxy: request URI must be absolute
pass
# Send HTTP GET request
if ptype == "http" and not is_https:
request_uri = url
else:
request_uri = path
req_headers = (
f"GET {request_uri} HTTP/1.1\r\n"
f"Host: {domain}\r\n"
f"User-Agent: Mozilla/5.0 vpngate-openvpn-manager/2.0\r\n"
f"Accept: text/plain,*/*\r\n"
f"{proxy_basic_auth_header(proxy_user, proxy_pass or '') if ptype == 'http' and not is_https and proxy_user is not None else ''}"
f"Connection: close\r\n\r\n"
)
s.sendall(req_headers.encode('utf-8'))
# Read response
response_data = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
response_data += chunk
if len(response_data) > snapshot_utils.MAX_SNAPSHOT_BYTES + 65536:
raise RuntimeError("API response exceeds the maximum allowed size")
finally:
if s is not None:
try:
s.close()
except Exception:
pass
# Parse HTTP response
header_end = response_data.find(b"\r\n\r\n")
if header_end == -1:
raise RuntimeError("Invalid HTTP response format")
headers_part = response_data[:header_end].decode('utf-8', errors='replace')
body_part = response_data[header_end+4:]
# Check for HTTP status code
lines = headers_part.splitlines()
if not lines:
raise RuntimeError("Empty response headers")
status_line = lines[0]
status_parts = status_line.split()
if len(status_parts) >= 2:
try:
status_code = int(status_parts[1])
if status_code != 200:
raise RuntimeError(f"HTTP Server returned status {status_code}: {status_line}")
except ValueError:
pass
# Handle chunked transfer encoding
is_chunked = False
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1)
if k.strip().lower() == "transfer-encoding" and "chunked" in v.lower():
is_chunked = True
break
if is_chunked:
decoded = b""
idx = 0
while idx < len(body_part):
c_end = body_part.find(b"\r\n", idx)
if c_end == -1:
break
chunk_size_str = body_part[idx:c_end].split(b";")[0].strip()
try:
chunk_size = int(chunk_size_str, 16)
except ValueError:
break
if chunk_size == 0:
break
idx = c_end + 2
decoded += body_part[idx : idx + chunk_size]
idx += chunk_size + 2
body_part = decoded
return body_part.decode('utf-8', errors='replace')
def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str:
if url is None:
url = API_URL
ptype, phost, pport = vpn_utils.get_upstream_proxy()
if ptype and phost and pport:
try:
print(f"[fetch_api_text] 监测到上游代理 ({ptype}://{phost}:{pport}),尝试通过代理获取 API...", flush=True)
return fetch_api_text_via_proxy(url, ptype, phost, pport, use_ssl_verify)
except Exception as e:
print(f"[fetch_api_text] 通过代理获取 API 失败: {e},尝试使用直连/默认系统代理...", flush=True)
log_to_json("WARNING", "Main", f"使用代理 {ptype}://{phost}:{pport} 获取 API 失败: {e}")
request = urllib.request.Request(
url,
headers={
"User-Agent": f"Mozilla/5.0 AimiliVPN/{APP_VERSION}",
"Accept": "text/plain,*/*",
},
)
def read_limited(response: Any) -> bytes:
chunks: list[bytes] = []
total = 0
while True:
chunk = response.read(65536)
if not chunk:
break
total += len(chunk)
if total > snapshot_utils.MAX_SNAPSHOT_BYTES:
raise RuntimeError("API response exceeds the maximum allowed size")
chunks.append(chunk)
return b"".join(chunks)
if url.startswith("https://") and not use_ssl_verify:
import ssl
ctx = ssl._create_unverified_context()
with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS, context=ctx) as response:
return read_limited(response).decode("utf-8", errors="replace")
else:
with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS) as response:
return read_limited(response).decode("utf-8", errors="replace")
def fetch_api_text_with_deadline(
url: str,
use_ssl_verify: bool = True,
deadline_seconds: int | None = None,
) -> str:
deadline = deadline_seconds or API_SOURCE_DEADLINE_SECONDS
result_queue: queue.Queue[tuple[bool, Any]] = queue.Queue(maxsize=1)
def worker() -> None:
try:
result_queue.put((True, fetch_api_text(url, use_ssl_verify)))
except BaseException as exc:
result_queue.put((False, exc))
threading.Thread(target=worker, daemon=True).start()
try:
ok, value = result_queue.get(timeout=deadline)
except queue.Empty as exc:
raise SourceDeadlineExceeded(f"节点源超过 {deadline} 秒总时限") from exc
if ok:
return str(value)
raise value
def parse_release_version(value: Any) -> tuple[int, int, int]:
match = re.search(r"(?i)(?:^|[^a-z0-9])v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", str(value or "").strip())
if not match:
raise ValueError("GitHub Release 版本号格式无效")
return tuple(int(part or 0) for part in match.groups())
def check_latest_release() -> dict[str, Any]:
payload = json.loads(fetch_api_text(GITHUB_LATEST_RELEASE_API, True))
if not isinstance(payload, dict):
raise ValueError("GitHub Release API 返回格式无效")
if payload.get("draft") or payload.get("prerelease"):
raise ValueError("GitHub 最新版本不是正式版")
latest_tag = str(payload.get("tag_name") or "").strip()
latest_version = parse_release_version(latest_tag)
current_version = parse_release_version(APP_VERSION)
release_url = f"{GITHUB_REPOSITORY_URL}/releases/tag/{urllib.parse.quote(latest_tag, safe='')}"
return {
"ok": True,
"current_version": APP_VERSION,
"current_version_label": APP_VERSION_LABEL,
"latest_version": ".".join(str(part) for part in latest_version),
"latest_tag": latest_tag,
"latest_name": str(payload.get("name") or latest_tag),
"published_at": str(payload.get("published_at") or ""),
"update_available": latest_version > current_version,
"release_url": release_url,
"main_branch_url": GITHUB_MAIN_BRANCH_URL,
"deployment_mode": DEPLOYMENT_MODE,
"deployment_mode_label": DEPLOYMENT_MODE_LABEL,
"update_command": UPDATE_COMMAND,
}
def is_certificate_verification_error(exc: BaseException) -> bool:
import ssl
current: BaseException | None = exc
seen: set[int] = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
if isinstance(current, ssl.SSLCertVerificationError):
return True
reason = getattr(current, "reason", None)
cause = getattr(current, "__cause__", None)
current = reason if isinstance(reason, BaseException) else cause
return False
def parse_vpngate_rows(text: str) -> list[dict[str, str]]:
return snapshot_utils.parse_and_validate_snapshot(text, max_rows=MAX_SCAN_ROWS)
def decode_config(encoded: str) -> str:
return snapshot_utils.decode_config(encoded)
def load_blacklist() -> dict[str, dict[str, Any]]:
now = time.time()
raw = read_json(BLACKLIST_FILE, {})
if not isinstance(raw, dict):
return {}
cleaned: dict[str, dict[str, Any]] = {}
changed = False
for key, entry in raw.items():
if not isinstance(entry, dict):
changed = True
continue
until = float(entry.get("until", 0) or 0)
if until and until > now:
cleaned[str(key)] = entry
else:
changed = True
if changed:
write_json(BLACKLIST_FILE, cleaned)
return cleaned
def mark_blacklisted(node: dict[str, Any], message: str) -> None:
node_id = str(node.get("id") or "").strip()
if not node_id:
return
blacklist = load_blacklist()
now = time.time()
blacklist[node_id] = {
"id": node_id,
"ip": node.get("ip") or node.get("remote_host") or "",
"country": node.get("country", ""),
"reason": message,
"marked_at": now,
"until": now + INVALID_BACKOFF_SECONDS,
}
write_json(BLACKLIST_FILE, blacklist)
def row_to_node(row: dict[str, str], config_text: str) -> dict[str, Any]:
ip = row.get("IP", "")
country_short = row.get("CountryShort", "")
remote_host, remote_port, proto = vpn_utils.parse_remote(config_text, ip)
node_id = safe_name("_".join([country_short or "XX", ip or remote_host, str(remote_port), proto]))
config_path = CONFIG_DIR / f"{node_id}.ovpn"
country_long = row.get("CountryLong", "")
country_zh = vpn_utils.COUNTRY_TRANSLATIONS.get(country_long, vpn_utils.COUNTRY_TRANSLATIONS.get(country_long.strip(), country_long))
return {
"id": node_id,
"country": country_zh,
"country_short": country_short,
"host_name": row.get("HostName", ""),
"ip": ip,
"score": parse_int(row.get("Score")),
"ping": parse_int(row.get("Ping")),
"speed": parse_int(row.get("Speed")),
"sessions": parse_int(row.get("NumVpnSessions")),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
"latency_ms": 0,
"config_file": str(config_path),
"config_text": config_text,
"proto": proto,
"remote_host": remote_host,
"remote_port": remote_port,
"fetched_at": time.time(),
"probe_status": "not_checked",
"probe_message": "",
"probed_at": 0,
}
def api_network_sources() -> list[tuple[str, str]]:
configured = [
("official_https", API_HTTPS_URL),
("official_http", API_HTTP_URL),
("github_pages_https", MIRROR_HTTPS_URL),
("github_pages_http", MIRROR_HTTP_URL),
]
sources: list[tuple[str, str]] = []
seen: set[str] = set()
for label, url in configured:
normalized = str(url or "").strip()
if not normalized or normalized in seen:
continue
if not normalized.startswith(("https://", "http://")):
print(f"[配置警告] 忽略不支持的节点源 URL: {normalized}", flush=True)
continue
seen.add(normalized)
sources.append((label, normalized))
return sources
def read_snapshot_file(path: Path) -> str:
size = path.stat().st_size
if size <= 0 or size > snapshot_utils.MAX_SNAPSHOT_BYTES:
raise ValueError(f"本地快照大小无效: {size}")
return path.read_bytes().decode("utf-8", errors="strict")
def cache_api_snapshot(text: str, source: str) -> None:
validated_rows = snapshot_utils.parse_and_validate_snapshot(text, max_rows=MAX_SCAN_ROWS)
encoded = text.encode("utf-8")
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
tmp = API_CACHE_FILE.with_suffix(API_CACHE_FILE.suffix + ".tmp")
tmp.write_bytes(encoded)
tmp.replace(API_CACHE_FILE)
write_json(
API_CACHE_META_FILE,
{
"source": source,
"cached_at": time.time(),
"row_count": len(validated_rows),
"byte_count": len(encoded),
"sha256": hashlib.sha256(encoded).hexdigest(),
},
)
def rows_to_candidates(
rows: list[dict[str, str]],
blacklist: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
candidates: list[dict[str, Any]] = []
seen_ips: set[str] = set()
for row in rows[:MAX_SCAN_ROWS]:
ip = row.get("IP", "")
if not ip or ip in seen_ips:
continue
try:
config_text = decode_config(row.get("OpenVPN_ConfigData_Base64", ""))
snapshot_utils.validate_openvpn_config(config_text)
node = row_to_node(row, config_text)
except Exception as row_exc:
print(f"[fetch_candidates] 跳过损坏或不安全的节点配置记录: {row_exc}", flush=True)
log_to_json("WARNING", "Main", f"跳过损坏或不安全的节点配置记录: {row_exc}")
continue
entry = blacklist.get(node["id"])
if entry and float(entry.get("until", 0) or 0) > time.time():
continue
candidates.append(node)
seen_ips.add(ip)
return candidates
def filter_candidates_by_discovery_countries(
candidates: list[dict[str, Any]],
country_codes: Any,
) -> list[dict[str, Any]]:
selected = set(normalize_discovery_countries(country_codes))
if not selected:
return candidates
return [
candidate
for candidate in candidates
if str(candidate.get("country_short") or "").strip().upper() in selected
]
def fetch_candidates() -> list[dict[str, Any]]:
blacklist = load_blacklist()
discovery_countries = normalize_discovery_countries(
load_ui_config().get("discovery_countries")
)
last_err: Exception | None = None
deadline_hosts: set[str] = set()
log_to_json("INFO", "Main", "开始按官方、GitHub Pages、本地缓存顺序拉取节点列表...")
for source_name, url in api_network_sources():
source_host = (urllib.parse.urlsplit(url).hostname or "").lower()
if url.startswith("http://") and source_host in deadline_hosts:
msg = f"跳过同主机慢速 HTTP 节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True)
log_to_json("WARNING", "Main", msg)
continue
try:
msg = f"尝试节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True)
log_to_json("INFO", "Main", msg)
api_text = fetch_api_text_with_deadline(url, True)
rows = parse_vpngate_rows(api_text)
candidates = rows_to_candidates(rows, blacklist)
if not candidates:
raise ValueError("节点源通过格式校验,但没有未被屏蔽的候选节点")
# Plain HTTP remains available for older machines, but never replaces
# the last snapshot obtained through an authenticated HTTPS channel.
if url.startswith("https://"):
cache_api_snapshot(api_text, source_name)
filtered_candidates = filter_candidates_by_discovery_countries(
candidates,
discovery_countries,
)
scope_message = (
f"按国家范围 {', '.join(discovery_countries)} 筛选后保留 {len(filtered_candidates)} 个"
if discovery_countries
else f"保留全部 {len(filtered_candidates)} 个"
)
set_state(
last_fetch_at=time.time(),
last_fetch_status="ok",
last_fetch_source=source_name,
last_fetch_message=(
f"从 {source_name} 成功获取 {len(candidates)} 个候选节点,{scope_message}。"
),
blacklisted_nodes=len(blacklist),
)
log_to_json(
"INFO",
"Main",
f"节点源 {source_name} 获取成功,共 {len(candidates)} 个候选节点,{scope_message}",
)
return filtered_candidates
except Exception as e:
last_err = e
if isinstance(e, SourceDeadlineExceeded) and url.startswith("https://"):
deadline_hosts.add(source_host)
print(f"[fetch_candidates] 节点源 {source_name} 失败: {e}", flush=True)
log_to_json("WARNING", "Main", f"节点源 {source_name} 失败: {e}")
local_sources = [("local_cache", API_CACHE_FILE)]
if BUNDLED_SNAPSHOT_FILE != API_CACHE_FILE:
local_sources.append(("bundled_initial", BUNDLED_SNAPSHOT_FILE))
for source_name, path in local_sources:
try:
if not path.exists():
continue
api_text = read_snapshot_file(path)
rows = parse_vpngate_rows(api_text)
candidates = rows_to_candidates(rows, blacklist)
if not candidates:
raise ValueError("本地快照没有未被屏蔽的候选节点")
if source_name == "bundled_initial" and not API_CACHE_FILE.exists():
cache_api_snapshot(api_text, source_name)
filtered_candidates = filter_candidates_by_discovery_countries(
candidates,
discovery_countries,
)
scope_message = (
f"按国家范围 {', '.join(discovery_countries)} 筛选后保留 {len(filtered_candidates)} 个"
if discovery_countries
else f"保留全部 {len(filtered_candidates)} 个"
)
set_state(
last_fetch_at=time.time(),
last_fetch_status="cached",
last_fetch_source=source_name,
last_fetch_message=(
f"网络节点源不可用,已载入 {source_name}{len(candidates)} 个候选节点,"
f"{scope_message}。"
),
blacklisted_nodes=len(blacklist),
)
log_to_json(
"WARNING",
"Main",
f"网络节点源不可用,使用 {source_name},共 {len(candidates)} 个候选节点,{scope_message}",
)
return filtered_candidates
except Exception as e:
last_err = e
print(f"[fetch_candidates] 本地节点源 {source_name} 失败: {e}", flush=True)
log_to_json("WARNING", "Main", f"本地节点源 {source_name} 失败: {e}")
err_code, diag_msg = vpn_utils.diagnose_api_failure(API_URL)
full_err_msg = f"所有节点源和本地缓存均失败: {last_err} | 诊断结果: {diag_msg}"
print(f"[错误代码 {err_code}] {full_err_msg}", flush=True)
log_to_json("ERROR", "Main", f"[错误代码 {err_code}] {full_err_msg}")
set_state(
last_fetch_status="error",
last_fetch_error_code=err_code,
last_fetch_source="",
last_fetch_message=diag_msg,
)
if last_err:
raise RuntimeError(diag_msg) from last_err
raise RuntimeError(diag_msg)
def cached_nodes() -> list[dict[str, Any]]:
return read_nodes()
_openvpn_version = None
def split_openvpn_command() -> list[str]:
try:
return shlex.split(OPENVPN_CMD, posix=(os.name != "nt")) or ["openvpn"]
except ValueError as exc:
raise RuntimeError(f"OPENVPN_CMD 配置无法解析: {exc}") from exc
def get_openvpn_version() -> float:
global _openvpn_version
if _openvpn_version is not None:
return _openvpn_version
try:
cmd = split_openvpn_command()
res = subprocess.run(cmd + ["--version"], capture_output=True, text=True, timeout=2)
match = re.search(r"OpenVPN\s+(\d+\.\d+)", res.stdout or res.stderr)
if match:
_openvpn_version = float(match.group(1))
return _openvpn_version
except Exception:
pass
_openvpn_version = 2.4
return _openvpn_version
def openvpn_command(config_file: str, route_nopull: bool, dev: str = "tun0") -> list[str]:
command = split_openvpn_command()
command.extend(
[
"--config",
config_file,
"--dev",
dev,
"--dev-type",
"tun",
"--pull-filter",
"ignore",
"route-ipv6",
"--pull-filter",
"ignore",
"ifconfig-ipv6",
"--route-delay",
"2",
"--connect-retry-max",
"1",
"--connect-timeout",
"15",
"--auth-user-pass",
str(AUTH_FILE),
"--auth-nocache",
]
)
version = get_openvpn_version()
if version >= 2.5:
command.extend(["--data-ciphers", "AES-128-CBC:AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305"])
else:
command.extend(["--ncp-ciphers", "AES-128-CBC:AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305"])
command.extend(["--verb", "3"])
if os.path.exists("/etc/ssl/certs"):
command.extend(["--capath", "/etc/ssl/certs"])
try:
content = Path(config_file).read_text(encoding="utf-8", errors="replace")
if vpn_utils.is_config_tcp(content):
ptype, host, port = vpn_utils.get_upstream_proxy()
auth_file = upstream_proxy_auth_file()
if ptype == "socks" and host and port:
command.extend(["--socks-proxy", host, str(port)])
if auth_file:
command.append(auth_file)
elif ptype == "http" and host and port:
command.extend(["--http-proxy", host, str(port)])
if auth_file:
command.append(auth_file)
except Exception:
pass
if route_nopull:
command.append("--route-nopull")
return command
def stop_process(process: subprocess.Popen[str] | None) -> None:
if process is None or process.poll() is not None:
return
try:
process.terminate()
except OSError:
return
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
try:
process.kill()
except OSError:
pass
def begin_connection_attempt() -> tuple[int, threading.Event]:
global connection_epoch, active_connection_cancel_event, is_connecting
if not connection_attempt_lock.acquire(blocking=False):
raise RuntimeError("当前已有连接切换任务正在运行,请稍后再试")
cancel_event = threading.Event()
with lock:
if is_connecting:
connection_attempt_lock.release()
raise RuntimeError("当前已有连接或节点检测任务正在运行,请稍后再试")
connection_epoch += 1
token = connection_epoch
active_connection_cancel_event = cancel_event
is_connecting = True
return token, cancel_event
def connection_attempt_is_current(token: int, cancel_event: threading.Event) -> bool:
with lock:
return token == connection_epoch and not cancel_event.is_set()
def finish_connection_attempt(token: int, cancel_event: threading.Event) -> None:
global active_connection_cancel_event, is_connecting
with lock:
if active_connection_cancel_event is cancel_event:
active_connection_cancel_event = None
if token == connection_epoch:
is_connecting = False
connection_attempt_lock.release()
def cancel_pending_connection_attempt() -> None:
global connection_epoch, pending_openvpn_process, is_connecting
pending = None
with lock:
if active_connection_cancel_event is None:
return
connection_epoch += 1
active_connection_cancel_event.set()
pending = pending_openvpn_process
pending_openvpn_process = None
is_connecting = False
stop_process(pending)
def kill_existing_openvpn_processes() -> None:
if not sys.platform.startswith("linux"):
return
try:
own_markers = [
str(DATA_DIR),
str(CONFIG_DIR),
str(AUTH_FILE),
str(UPSTREAM_PROXY_AUTH_FILE),
]
killed_pids: list[int] = []
proc_root = Path("/proc")
if not proc_root.exists():
return
for proc_dir in proc_root.iterdir():
if not proc_dir.name.isdigit():
continue
pid = int(proc_dir.name)
if pid == os.getpid():
continue
try:
raw = (proc_dir / "cmdline").read_bytes()
except OSError:
continue
if not raw:
continue
args = [part.decode("utf-8", errors="replace") for part in raw.split(b"\0") if part]
if not args:
continue
cmdline = " ".join(args)
executable = Path(args[0]).name.lower()
if "openvpn" not in executable and "openvpn" not in cmdline.lower():
continue
if any(marker and marker in cmdline for marker in own_markers):
try:
os.kill(pid, signal.SIGTERM)
killed_pids.append(pid)
except ProcessLookupError:
pass
except PermissionError:
print(f"[Cleanup] No permission to terminate OpenVPN PID {pid}", flush=True)
if killed_pids:
time.sleep(0.5)
for pid in killed_pids:
try:
raw = (proc_root / str(pid) / "cmdline").read_bytes()
cmdline = " ".join(part.decode("utf-8", errors="replace") for part in raw.split(b"\0") if part)
if any(marker and marker in cmdline for marker in own_markers):
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except (OSError, PermissionError):
pass
print(f"[Cleanup] Terminated AimiliVPN OpenVPN processes: {killed_pids}", flush=True)
except Exception as e:
print(f"[Cleanup Error] Failed to kill existing OpenVPN processes: {e}", flush=True)
def update_handshake_status(line_lower: str) -> None:
status_map = {
"resolving": ("解析域名", "正在解析服务器域名与 IP 地址..."),
"udp link local": ("物理连接", "已创建本地套接字,开始尝试发送数据包..."),
"tcp link local": ("物理连接", "已创建本地套接字,开始尝试发送数据包..."),
"tls: initial packet": ("证书握手", "已成功发送首包,正在与远程服务器建立 TLS 安全通道..."),
"verify ok": ("证书校验", "服务器证书校验成功,正在进行身份验证..."),
"peer connection initiated": ("协商加密", "控制通道已建立,已初始化与服务器的加密对等连接..."),
"push_request": ("请求配置", "正在向服务器发送 PUSH_REQUEST 请求配置参数与 IP 分配..."),
"push_reply": ("应用配置", "已接收服务器 PUSH_REPLY,获取到 IP 分配,正在准备配置网卡..."),
"tun/tap device": ("创建网卡", "正在创建虚拟通道并打开 TUN 虚拟网卡设备..."),
"do_ifconfig": ("网卡配置", "正在为虚拟网卡配置 IP 地址及相关网络属性..."),
}
for key, (short_status, detailed_desc) in status_map.items():
if key in line_lower:
set_state(active_node_latency=short_status, last_check_message=detailed_desc)
break
def run_openvpn_until_ready(
config_file: str,
keep_alive: bool,
route_nopull: bool,
timeout: int | None = None,
dev: str = "tun0",
cancel_event: threading.Event | None = None,
track_pending: bool = False,
) -> tuple[bool, str, subprocess.Popen[str] | None]:
global pending_openvpn_process
limit = timeout if timeout is not None else OPENVPN_TEST_TIMEOUT_SECONDS
try:
process = subprocess.Popen(
openvpn_command(config_file, route_nopull, dev),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(ROOT_DIR),
)
except FileNotFoundError:
return False, "[错误代码 2001] [ERR_OVPN_CMD_NOT_FOUND] 未找到 openvpn 命令。原因: 系统未安装 openvpn,或 PATH 环境变量不正确。", None
except OSError as exc:
return False, f"[错误代码 2002] [ERR_OVPN_START_FAILED] openvpn 启动失败: {exc}。原因: 系统权限不足或配置冲突。", None
if track_pending:
with lock:
if cancel_event is not None and cancel_event.is_set():
stop_process(process)
return False, "连接操作已取消", None
pending_openvpn_process = process
lines: queue.Queue[str | None] = queue.Queue()
startup_done = [False]
openvpn_logs: list[str] = []
def reader() -> None:
assert process.stdout is not None
for line in process.stdout:
line_str = line.rstrip()
if not startup_done[0]:
openvpn_logs.append(line_str)
lines.put(line_str)
else:
if keep_alive:
print(f"[OpenVPN] {line_str}", flush=True)
level = "INFO"
line_lower = line_str.lower()
if "error" in line_lower or "failed" in line_lower or "cannot" in line_lower or "fatal" in line_lower or "permission denied" in line_lower:
level = "ERROR"
elif "warning" in line_lower or "warn" in line_lower or "deprecated" in line_lower:
level = "WARNING"
log_to_json(level, "VPN", f"[OpenVPN] {line_str}")
if not startup_done[0]:
lines.put(None)
threading.Thread(target=reader, daemon=True).start()
started = time.time()
tail: list[str] = []
ok = False
cancelled = False
message = "OpenVPN did not complete initialization."
while time.time() - started < limit:
if cancel_event is not None and cancel_event.is_set():
cancelled = True
message = "连接操作已取消"
break
try:
line = lines.get(timeout=0.5)
except queue.Empty:
if process.poll() is not None:
break
continue
if line is None:
break
if line:
tail.append(line)
tail = tail[-50:]
if keep_alive:
print(f"[OpenVPN] {line}", flush=True)
lower = line.lower()
if keep_alive:
update_handshake_status(lower)
if "initialization sequence completed" in lower:
if cancel_event is not None and cancel_event.is_set():
cancelled = True
message = "连接操作已取消"
else:
ok = True
message = f"OpenVPN connected in {int((time.time() - started) * 1000)} ms."
break
if "auth_failed" in lower or "authentication failed" in lower:
message = "AUTH_FAILED"
break
if "cannot ioctl" in lower or "fatal error" in lower:
message = line[-220:]
break
else:
message = f"OpenVPN timeout after {limit}s."
# Bulk write accumulated startup logs
for line_str in openvpn_logs:
level = "INFO"
line_lower = line_str.lower()
if "error" in line_lower or "failed" in line_lower or "cannot" in line_lower or "fatal" in line_lower or "permission denied" in line_lower:
level = "ERROR"
elif "warning" in line_lower or "warn" in line_lower or "deprecated" in line_lower:
level = "WARNING"
log_to_json(level, "VPN", f"[OpenVPN] {line_str}")
if not ok and not cancelled:
err_code, diag_msg = vpn_utils.diagnose_openvpn_failure(tail)
message = f"[错误代码 {err_code}] {diag_msg} (原始日志尾部: {tail[-1][-100:] if tail else '无'})"
startup_done[0] = True
if not keep_alive or not ok:
stop_process(process)
process = None
if track_pending:
with lock:
if pending_openvpn_process is process or pending_openvpn_process is not None and pending_openvpn_process.poll() is not None:
pending_openvpn_process = None
return ok, message, process
def setup_policy_routing(interface: str = "tun0") -> bool:
try:
subprocess.run(["ip", "rule", "del", "table", "100"], capture_output=True, timeout=2)
except Exception:
pass
try:
subprocess.run(["ip", "route", "flush", "table", "100"], capture_output=True, timeout=2)
except Exception:
pass
success = False
for attempt in range(1, 4):
try:
subprocess.run(["ip", "route", "add", "default", "dev", interface, "table", "100"], check=True, timeout=2)
subprocess.run(["ip", "rule", "add", "oif", interface, "table", "100"], check=True, timeout=2)
# 配置反向路径过滤 rp_filter 为 loose 模式 (2),防止回包被内核静默丢弃
for proc_path in ["all", "default", interface]:
try:
subprocess.run(["sysctl", "-w", f"net.ipv4.conf.{proc_path}.rp_filter=2"], capture_output=True, timeout=2)
except Exception:
pass
print(f"[policy_routing] Enabled policy routing for interface {interface} (attempt {attempt} success)", flush=True)
success = True
break
except Exception as e:
print(f"[policy_routing] Attempt {attempt} failed to enable policy routing: {e}", flush=True)
time.sleep(1)
if not success:
print("[路由配置失败] [错误代码 3003] [ERR_ROUTE_TABLE_ADD_FAILED] 策略路由配置失败。原因: 无法向路由表 100 添加默认路由,这可能会导致通过 VPN 接口的出站路由无法正常解析。请检查系统是否支持策略路由、iproute2 工具是否完整,以及是否具有 root 权限。", flush=True)
log_to_json("ERROR", "Routing", "[错误代码 3003] [ERR_ROUTE_TABLE_ADD_FAILED] 策略路由配置失败。原因: 无法向路由表 100 添加默认路由")
return success
def cleanup_policy_routing() -> None:
try:
subprocess.run(["ip", "rule", "del", "table", "100"], capture_output=True, timeout=2)
subprocess.run(["ip", "route", "flush", "table", "100"], capture_output=True, timeout=2)
print("[policy_routing] Cleared policy routing table 100", flush=True)
except Exception:
pass
def stop_active_openvpn() -> None:
global active_openvpn_process, active_openvpn_node_id
with lock:
cleanup_policy_routing()
config_to_delete = None
if active_openvpn_node_id:
nodes = read_nodes()
node = next((item for item in nodes if item.get("id") == active_openvpn_node_id), None)
if node:
config_to_delete = node.get("config_file")
stop_process(active_openvpn_process)
active_openvpn_process = None
active_openvpn_node_id = ""
if config_to_delete:
try:
path = Path(config_to_delete)
if path.exists():
path.unlink()
except Exception:
pass
def active_openvpn_running() -> bool:
return active_openvpn_process is not None and active_openvpn_process.poll() is None
def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
available_nodes = sorted(
[n for n in nodes if n.get("probe_status") == "available" or n.get("active")],
key=lambda n: (
0 if n.get("ip_type") in ("residential", "mobile") else 1,
parse_int(n.get("latency_ms")) or 999999,
-parse_int(n.get("score"))
)
)
untested_nodes = sorted(
[n for n in nodes if n.get("probe_status") in ("not_checked", "testing") and not n.get("active")],
key=lambda n: (-parse_int(n.get("score")), parse_int(n.get("ping")))
)
unavailable_nodes = sorted(
[n for n in nodes if n.get("probe_status") == "unavailable" and not n.get("active")],
key=lambda n: (-parse_int(n.get("score")), -float(n.get("probed_at", 0)))
)
return available_nodes + untested_nodes + unavailable_nodes
def enrich_stored_nodes() -> int:
"""Enrich every listed IP, then merge only metadata into the latest state."""
with lock:
snapshot = read_nodes()
if not snapshot:
return 0
vpn_utils.enrich_ip_info(snapshot)
enriched_by_id = {
str(node.get("id") or ""): node
for node in snapshot
if node.get("id") and node.get("ip_type")
}
if not enriched_by_id:
return 0
changed = 0
with lock:
current_nodes = read_nodes()
for current in current_nodes:
enriched = enriched_by_id.get(str(current.get("id") or ""))
if not enriched:
continue
for field in IP_ENRICHMENT_FIELDS:
new_value = enriched.get(field, "")
if current.get(field, "") != new_value:
current[field] = new_value
changed += 1
if changed:
write_json(NODES_FILE, sort_all_nodes(current_nodes))
return changed
def ip_enrichment_loop() -> None:
while True:
nodes_exist = bool(read_nodes())
if nodes_exist:
try:
enrich_stored_nodes()
except Exception as exc:
print(f"[IP 类型] 后台批量识别失败: {exc}", flush=True)
log_to_json("WARNING", "Main", f"后台批量识别 IP 类型失败: {exc}")
ip_enrichment_wakeup.wait(300 if nodes_exist else 5)
ip_enrichment_wakeup.clear()
def apply_routing_filters(
nodes: list[dict[str, Any]],
ui_cfg: dict[str, Any],
include_unknown_ip_type: bool = False,
) -> list[dict[str, Any]]:
candidates = list(nodes)
routing_mode = ui_cfg.get("routing_mode", "auto")
target_country = ui_cfg.get("force_country", "")
if routing_mode == "fixed_region" and target_country:
candidates = [
n for n in candidates
if country_matches(n.get("country"), target_country)
]
elif routing_mode == "favorites":
fav_ids = set(ui_cfg.get("favorite_node_ids", []))
candidates = [n for n in candidates if n.get("id") in fav_ids]
routing_ip_type = ui_cfg.get("routing_ip_type", "all")
if routing_ip_type == "residential":
candidates = [
n for n in candidates
if n.get("ip_type") in ("residential", "mobile")
or (include_unknown_ip_type and not n.get("ip_type"))
]
elif routing_ip_type == "hosting":
candidates = [
n for n in candidates
if n.get("ip_type") == "hosting"
or (include_unknown_ip_type and not n.get("ip_type"))
]
return candidates
def normalized_country_name(country: Any) -> str:
value = str(country or "").strip()
return vpn_utils.COUNTRY_TRANSLATIONS.get(value, value)
def country_matches(node_country: Any, target_country: Any) -> bool:
return bool(target_country) and normalized_country_name(node_country) == normalized_country_name(target_country)
def probe_priority_key(node: dict[str, Any]) -> tuple[int, int, int, int]:
ping = parse_int(node.get("ping")) or 999999
return (
ping,
-parse_int(node.get("score")),
-parse_int(node.get("speed")),
parse_int(node.get("sessions")),
)
def current_fixed_node_id(ui_cfg: dict[str, Any]) -> str:
if active_openvpn_node_id:
return active_openvpn_node_id
nodes = read_nodes()
active_node = next((n for n in nodes if n.get("active") and n.get("id")), None)
if active_node:
return str(active_node.get("id") or "")
return str(ui_cfg.get("fixed_node_id") or "").strip()
def validate_node_allowed_by_routing(node: dict[str, Any], ui_cfg: dict[str, Any]) -> None:
routing_mode = ui_cfg.get("routing_mode", "auto")
node_id = str(node.get("id") or "")
if routing_mode == "fixed_region":
target_country = ui_cfg.get("force_country", "")
if target_country and not country_matches(node.get("country"), target_country):
raise RuntimeError(f"当前已锁定国家【{target_country}】,不能连接其他国家节点")
elif routing_mode == "favorites":
fav_ids = set(ui_cfg.get("favorite_node_ids", []))
if node_id not in fav_ids:
raise RuntimeError("当前处于仅用收藏模式,不能连接未收藏节点")
routing_ip_type = ui_cfg.get("routing_ip_type", "all")
node_ip_type = node.get("ip_type")
if routing_ip_type == "residential" and node_ip_type not in ("residential", "mobile"):
raise RuntimeError("当前已锁定住宅 IP 出站,不能连接非住宅节点")
if routing_ip_type == "hosting" and node_ip_type != "hosting":
raise RuntimeError("当前已锁定机房 IP 出站,不能连接非机房节点")
def enforce_active_node_allowed_by_routing(ui_cfg: dict[str, Any], reason: str = "路由规则已更新") -> str | None:
active_id = active_openvpn_node_id
if not active_id:
return None
nodes = read_nodes()
active_node = next((item for item in nodes if item.get("id") == active_id), None)
if not active_node:
clear_active_connection_state(f"{reason},当前活动节点已不在节点列表中,已断开连接")
return "当前活动节点已不在节点列表中,已断开连接"
try:
validate_node_allowed_by_routing(active_node, ui_cfg)
return None
except Exception as exc:
msg = f"{reason},当前活动节点 {active_id} 不符合新规则,已断开连接: {exc}"
print(f"[路由规则] {msg}", flush=True)
log_to_json("WARNING", "Routing", msg)
stop_active_openvpn()
with lock:
nodes = read_nodes()
for item in nodes:
item["active"] = False
write_json(NODES_FILE, nodes)
set_state(
active_openvpn_node_id="",
active_node_latency="无活动连接",
proxy_ok=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error=msg,
last_check_message=msg,
)
if ui_cfg.get("connection_enabled", True) and ui_cfg.get("routing_mode") != "fixed_ip":
threading.Thread(target=auto_switch_node, daemon=True).start()
return msg
def reconnect_fixed_node_if_needed(ui_cfg: dict[str, Any]) -> bool:
global is_connecting
if ui_cfg.get("routing_mode") != "fixed_ip" or active_openvpn_running():
return False
target_id = current_fixed_node_id(ui_cfg)
if not target_id:
return False
nodes = read_nodes()
if not any(n.get("id") == target_id for n in nodes):
return False
print(f"[维护线程] 固定 IP 模式下 OpenVPN 未运行,正在重新拉起同一节点: {target_id}", flush=True)
previous_connecting = is_connecting
is_connecting = False
try:
connect_node(target_id)
return active_openvpn_running()
except Exception as e:
print(f"[维护线程] 重新拉起固定节点 {target_id} 失败: {e}", flush=True)
return False
finally:
is_connecting = previous_connecting
active_test_indexes = set()
test_indexes_lock = threading.Lock()
def get_free_test_index() -> int:
with test_indexes_lock:
for idx in range(2, 100):
if idx not in active_test_indexes:
active_test_indexes.add(idx)
return idx
raise RuntimeError("没有可用的 OpenVPN 测试网卡编号,请稍后重试")
def release_test_index(idx: int) -> None:
with test_indexes_lock:
active_test_indexes.discard(idx)
def test_config_path(node_id: str) -> Path:
safe_id = safe_name(node_id)
return CONFIG_DIR / f".test_{safe_id}_{uuid.uuid4().hex}.ovpn"
def test_node_by_id(node_id: str) -> dict[str, Any]:
with lock:
nodes = read_nodes()
node = next((item for item in nodes if item.get("id") == node_id), None)
if not node:
raise ValueError(f"Node not found: {node_id}")
config_text = node.get("config_text") or ""
h = str(node.get("remote_host") or node.get("ip"))
p = parse_int(node.get("remote_port"))
fallback_ping = parse_int(node.get("ping"))
temp_path = test_config_path(node_id)
try:
CONFIG_DIR.mkdir(exist_ok=True, parents=True)
temp_path.write_text(config_text, encoding="utf-8")
except Exception as e:
raise RuntimeError(f"Failed to write temp config file: {e}")
latency = vpn_utils.ping_latency_ms(h, p, fallback_ping)
idx = None
try:
idx = get_free_test_index()
ok, message, _ = run_openvpn_until_ready(str(temp_path), keep_alive=False, route_nopull=True, timeout=12, dev=f"tun{idx}")
finally:
if idx is not None:
release_test_index(idx)
try:
if temp_path.exists():
temp_path.unlink()
except Exception:
pass
temp_node = {
"id": node_id,
"ip": h,
"remote_host": h,
"remote_port": p,
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
}
if ok:
vpn_utils.enrich_ip_info([temp_node])
with lock:
nodes = read_nodes()
node = next((item for item in nodes if item.get("id") == node_id), None)
if node:
node["latency_ms"] = latency
node["probe_status"] = "available" if ok else "unavailable"
node["probe_message"] = message
node["probed_at"] = time.time()
if ok:
node["owner"] = temp_node["owner"]
node["asn"] = temp_node["asn"]
node["as_name"] = temp_node["as_name"]
node["location"] = temp_node["location"]
node["ip_type"] = temp_node["ip_type"]
node["quality"] = temp_node["quality"]
sorted_nodes = sort_all_nodes(nodes)
write_json(NODES_FILE, sorted_nodes)
res = next((item for item in sorted_nodes if item.get("id") == node_id), node)
return res
else:
return {}
def is_systemic_probe_failure(message: Any) -> bool:
normalized = str(message or "").lower()
return any(
token in normalized
for token in (
"err_ovpn_cmd_not_found",
"err_ovpn_permission_denied",
"err_ovpn_tun_not_available",
"no such file or directory",
"cannot open tun/tap dev",
"cannot allocate tun",
)
)
def test_multiple_nodes(node_ids: list[str], target_available: int | None = None) -> list[dict[str, Any]]:
with lock:
nodes = read_nodes()
to_test = [n for n in nodes if n.get("id") in node_ids]
def test_worker(args: tuple[int, dict[str, Any]]) -> dict[str, Any]:
idx, n_info = args
node_id = n_info["id"]
config_text = n_info.get("config_text") or ""
h = str(n_info.get("remote_host") or n_info.get("ip"))
p = parse_int(n_info.get("remote_port"))
fallback_ping = parse_int(n_info.get("ping"))
temp_path = test_config_path(node_id)
try:
CONFIG_DIR.mkdir(exist_ok=True, parents=True)
temp_path.write_text(config_text, encoding="utf-8")
except Exception as e:
return {
"id": node_id,
"latency_ms": 0,
"probe_status": "unavailable",
"probe_message": f"Failed to write configuration: {e}",
"probed_at": time.time(),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
}
latency = vpn_utils.ping_latency_ms(h, p, fallback_ping)
tun_idx = None
try:
tun_idx = get_free_test_index()
dev_name = f"tun{tun_idx}"
ok, message, _ = run_openvpn_until_ready(str(temp_path), keep_alive=False, route_nopull=True, timeout=12, dev=dev_name)
finally:
if tun_idx is not None:
release_test_index(tun_idx)
try:
if temp_path.exists():
temp_path.unlink()
except Exception:
pass
temp_node = {
"id": node_id,
"ip": n_info.get("ip") or h,
"remote_host": h,
"remote_port": p,
"latency_ms": latency,
"probe_status": "available" if ok else "unavailable",
"probe_message": message,
"probed_at": time.time(),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
}
return temp_node
updated_nodes_map: dict[str, dict[str, Any]] = {}
available_count = 0
systemic_failure = ""
max_workers = min(NODE_PROBE_WORKERS, max(1, len(to_test)))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for batch_start in range(0, len(to_test), max_workers):
if systemic_failure or (target_available is not None and available_count >= target_available):
break
batch = to_test[batch_start : batch_start + max_workers]
batch_ids = {str(n.get("id") or "") for n in batch}
with lock:
current_nodes = read_nodes()
now = time.time()
for current in current_nodes:
if current.get("id") in batch_ids and not current.get("active"):
current["probe_status"] = "testing"
current["probe_message"] = "正在检测节点连通性..."
current["probed_at"] = now
write_json(NODES_FILE, sort_all_nodes(current_nodes))
futures = {
executor.submit(test_worker, (batch_start + idx, node)): node["id"]
for idx, node in enumerate(batch)
}
for future in concurrent.futures.as_completed(futures):
nid = futures[future]
try:
result = future.result()
except Exception as exc:
result = {
"id": nid,
"probe_status": "unavailable",
"probe_message": f"Test exception: {exc}",
"latency_ms": 0,
}
updated_nodes_map[nid] = result
if result.get("probe_status") == "available":
available_count += 1
if is_systemic_probe_failure(result.get("probe_message")):
systemic_failure = str(result.get("probe_message") or "")
with lock:
current_nodes = read_nodes()
for current in current_nodes:
if current.get("id") == nid:
current.update(result)
break
write_json(NODES_FILE, sort_all_nodes(current_nodes))
if systemic_failure:
message = f"检测到系统级 OpenVPN 故障,已停止剩余节点探测: {systemic_failure}"
print(f"[节点检测] {message}", flush=True)
log_to_json("ERROR", "VPN", message)
set_state(last_check_message=message)
break
# 批量查询并丰富可用节点的地理及 ISP 信息,防止并发时被定位 API 接口限流
successful_nodes = [res for res in updated_nodes_map.values() if res.get("probe_status") == "available"]
if successful_nodes:
try:
vpn_utils.enrich_ip_info(successful_nodes)
except Exception as ee:
print(f"[test_multiple_nodes] 批量富化 IP 失败: {ee}", flush=True)
with lock:
current_nodes = read_nodes()
for n in current_nodes:
nid = n.get("id")
if nid in updated_nodes_map:
n.update(updated_nodes_map[nid])
sorted_nodes = sort_all_nodes(current_nodes)
write_json(NODES_FILE, sorted_nodes)
return list(updated_nodes_map.values())
def cancel_background_refill() -> None:
background_refill_cancel_event.set()
def schedule_background_refill() -> bool:
global background_refill_thread
with background_refill_lock:
if background_refill_thread is not None and background_refill_thread.is_alive():
return False
background_refill_cancel_event.clear()
def refill_worker() -> None:
global background_refill_thread
try:
for delay in (60, 120, 300):
if background_refill_cancel_event.wait(delay):
return
ui_cfg = load_ui_config()
if not ui_cfg.get("connection_enabled", True):
return
try:
maintain_valid_nodes(force=False)
except Exception as exc:
log_to_json("WARNING", "Main", f"后台节点补齐失败: {exc}")
if active_openvpn_running():
return
finally:
with background_refill_lock:
if background_refill_thread is threading.current_thread():
background_refill_thread = None
background_refill_thread = threading.Thread(
target=refill_worker,
name="vpngate-node-refill",
daemon=True,
)
background_refill_thread.start()
return True
def auto_switch_node(attempt: int = 0) -> None:
if attempt >= 3:
print("[自动切换] 连续切换失败已达 3 次,停止切换以防止主线程死锁,将在后台重新加载节点...", flush=True)
return
ui_cfg = load_ui_config()
connection_enabled = ui_cfg.get("connection_enabled", True)
if not connection_enabled:
print("[自动切换] 连接已禁用,不进行自动切换。", flush=True)
return
routing_mode = ui_cfg.get("routing_mode", "auto")
target_country = ui_cfg.get("force_country", "")
if routing_mode == "fixed_ip":
print("[自动切换] 当前处于固定 IP 模式,不进行自动连接或切换。", flush=True)
return
# Find the next best available node
with lock:
nodes = read_nodes()
candidates = [
n for n in nodes
if n.get("probe_status") == "available"
and not n.get("active")
]
candidates = apply_routing_filters(candidates, ui_cfg)
candidates.sort(key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score"))))
if candidates:
next_node = candidates[0]
msg = f"当前连接已失效或代理连通性检测失败,正在自动切换至最佳备用节点: {next_node['id']}"
print(f"[自动切换] {msg}", flush=True)
log_to_json("INFO", "VPN", msg)
try:
connect_node(next_node["id"])
except Exception as e:
err_msg = f"切换到备用节点 {next_node['id']} 失败: {e},将尝试下一个..."
print(f"[自动切换] {err_msg}", flush=True)
log_to_json("WARNING", "VPN", err_msg)
auto_switch_node(attempt + 1)
else:
msg = "没有可用的备选节点,将自动断开并清理当前连接状态,同时在后台异步获取新节点..."
if routing_mode == "fixed_region" and target_country:
msg = f"没有可用的【{target_country}】备选节点,已断开连接,将在后台持续尝试获取新节点..."
print(f"[自动切换] {msg}", flush=True)
log_to_json("WARNING", "VPN", msg)
stop_active_openvpn()
with lock:
nodes = read_nodes()
for item in nodes:
item["active"] = False
write_json(NODES_FILE, nodes)
set_state(active_openvpn_node_id="", last_check_message=msg)
if schedule_background_refill():
log_to_json("INFO", "Main", "已启动唯一后台节点补齐任务")
def recover_after_manual_connect_failure(previous_node_id: str) -> None:
if active_openvpn_running():
return
if previous_node_id:
try:
log_to_json("WARNING", "VPN", f"手动切换失败,正在恢复原节点: {previous_node_id}")
connect_node(previous_node_id)
return
except Exception as exc:
log_to_json("ERROR", "VPN", f"恢复原节点 {previous_node_id} 失败: {exc}")
ui_cfg = load_ui_config()
if ui_cfg.get("connection_enabled", True) and ui_cfg.get("routing_mode") != "fixed_ip":
auto_switch_node()
def connect_node(node_id: str) -> str:
global active_openvpn_process, active_openvpn_node_id
global last_active_ping_time, last_active_latency
global consecutive_proxy_failures, last_proxy_failure_node_id
node_id = str(node_id or "").strip()
if not node_id:
raise ValueError("Node id is required")
token, cancel_event = begin_connection_attempt()
stopped_existing = False
previous_node_id = ""
try:
set_state(
is_connecting=True,
pending_node_id=node_id,
active_node_latency="正在连接",
last_check_message=f"正在初始化连接配置: {node_id}",
)
log_to_json("INFO", "VPN", f"开始连接节点: {node_id}")
nodes = read_nodes()
node = next((item for item in nodes if item.get("id") == node_id), None)
if not node:
raise ValueError(f"Node not found: {node_id}")
with lock:
if active_openvpn_running():
previous_node_id = active_openvpn_node_id
ui_cfg = load_ui_config()
validate_node_allowed_by_routing(node, ui_cfg)
ui_cfg["connection_enabled"] = True
auth_file = DATA_DIR / "ui_auth.json"
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
set_state(active_node_latency="写入配置", last_check_message="正在写入 OpenVPN 节点配置文件...")
config_path = Path(node["config_file"])
try:
CONFIG_DIR.mkdir(exist_ok=True, parents=True)
config_path.write_text(node.get("config_text") or "", encoding="utf-8")
except Exception as e:
raise RuntimeError(f"Failed to write configuration: {e}")
probed_at = float(node.get("probed_at", 0) or 0)
should_preflight = (
SWITCH_PREFLIGHT_MAX_AGE_SECONDS > 0
and bool(previous_node_id)
and previous_node_id != node_id
and time.time() - probed_at > SWITCH_PREFLIGHT_MAX_AGE_SECONDS
)
if should_preflight:
set_state(active_node_latency="切换预检", last_check_message="正在保持当前连接并预检目标节点...")
test_index = None
try:
test_index = get_free_test_index()
preflight_ok, preflight_message, _ = run_openvpn_until_ready(
str(config_path),
keep_alive=False,
route_nopull=True,
timeout=12,
dev=f"tun{test_index}",
cancel_event=cancel_event,
track_pending=True,
)
finally:
if test_index is not None:
release_test_index(test_index)
if not connection_attempt_is_current(token, cancel_event):
raise ConnectionCancelled("连接操作已取消")
if not preflight_ok:
with lock:
current_nodes = read_nodes()
failed_node = next((item for item in current_nodes if item.get("id") == node_id), None)
if failed_node:
failed_node["probe_status"] = "unavailable"
failed_node["probe_message"] = preflight_message
failed_node["probed_at"] = time.time()
write_json(NODES_FILE, sort_all_nodes(current_nodes))
raise RuntimeError(f"目标节点预检失败,已保留当前连接: {preflight_message}")
if not connection_attempt_is_current(token, cancel_event):
raise ConnectionCancelled("连接操作已取消")
set_state(active_node_latency="清理连接", last_check_message="目标节点可用,正在关闭旧的 VPN 连接及网卡...")
stop_active_openvpn()
stopped_existing = True
set_state(active_node_latency="启动核心", last_check_message="正在启动 OpenVPN Core 核心服务并建立连接...")
ok, message, process = run_openvpn_until_ready(
str(config_path),
keep_alive=True,
route_nopull=True,
cancel_event=cancel_event,
track_pending=True,
)
if not connection_attempt_is_current(token, cancel_event):
stop_process(process)
raise ConnectionCancelled("连接操作已取消")
if not ok or process is None:
try:
if config_path.exists():
config_path.unlink()
except Exception:
pass
node["probe_status"] = "unavailable"
node["probe_message"] = message
for item in nodes:
item["active"] = False
write_json(NODES_FILE, sort_all_nodes(nodes))
log_to_json("ERROR", "VPN", f"连接节点 {node_id} 失败: {message}")
print(f"[连接核心失败] 无法与 VPN 节点 {node_id} 建立隧道连接!详情: {message}", flush=True)
raise RuntimeError(message)
with lock:
if not connection_attempt_is_current(token, cancel_event):
stop_process(process)
raise ConnectionCancelled("连接操作已取消")
active_openvpn_process = process
active_openvpn_node_id = node_id
set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...")
routing_ready = setup_policy_routing("tun0")
if not connection_attempt_is_current(token, cancel_event):
raise ConnectionCancelled("连接操作已取消")
last_active_ping_time = time.time()
last_active_latency = 0
set_state(active_node_latency="测试延迟", last_check_message="正在直连测试代理出口延迟与可用性...")
try:
ip = node.get("ip") or node.get("remote_host")
port = parse_int(node.get("remote_port"))
fallback = parse_int(node.get("ping"))
latency = vpn_utils.ping_latency_ms(ip, port, fallback)
if latency > 0:
last_active_latency = latency
except Exception:
pass
set_state(last_check_message="正在测试本地代理出站联通性与出口 IP...")
res = check_proxy_health()
if not connection_attempt_is_current(token, cancel_event):
raise ConnectionCancelled("连接操作已取消")
if not res["ok"]:
route_note = ";策略路由配置失败" if not routing_ready else ""
raise RuntimeError(f"VPN 隧道已建立但代理出口不可用{route_note}: {res.get('error', '未知错误')}")
latest_ui_cfg = load_ui_config()
validate_node_allowed_by_routing(node, latest_ui_cfg)
latest_ui_cfg["connection_enabled"] = True
if latest_ui_cfg.get("routing_mode") == "fixed_ip":
latest_ui_cfg["fixed_node_id"] = node_id
latency_str = f"{last_active_latency} ms" if last_active_latency > 0 else "检测超时"
with lock:
if not connection_attempt_is_current(token, cancel_event):
raise ConnectionCancelled("连接操作已取消")
current_nodes = read_nodes()
for item in current_nodes:
item["active"] = item.get("id") == node_id
if item["active"]:
item["probe_status"] = "available"
item["probed_at"] = time.time()
_ph = f"[{LOCAL_PROXY_HOST}]" if ":" in LOCAL_PROXY_HOST else LOCAL_PROXY_HOST
item["probe_message"] = f"Active node. HTTP proxy: http://{_ph}:{LOCAL_PROXY_PORT}"
write_json(NODES_FILE, sort_all_nodes(current_nodes))
write_json(auth_file, latest_ui_cfg)
consecutive_proxy_failures = 0
last_proxy_failure_node_id = node_id
set_state(
active_openvpn_node_id=node_id,
is_connecting=False,
pending_node_id="",
last_check_message=f"Connected {node_id}",
active_node_latency=latency_str,
proxy_ok=True,
proxy_ip=res["ip"],
proxy_latency_ms=res["latency_ms"],
proxy_error="",
)
log_to_json("INFO", "VPN", f"节点 {node_id} 连接成功,出口网卡 tun0 已启用")
cancel_background_refill()
return f"Connected {node_id}"
except ConnectionCancelled:
if stopped_existing:
stop_active_openvpn()
raise
except Exception as exc:
if stopped_existing or (active_openvpn_node_id == node_id and not active_openvpn_running()):
with lock:
current_nodes = read_nodes()
failed_node = next((item for item in current_nodes if item.get("id") == node_id), None)
if failed_node:
failed_node["probe_status"] = "unavailable"
failed_node["probe_message"] = str(exc)
failed_node["probed_at"] = time.time()
write_json(NODES_FILE, sort_all_nodes(current_nodes))
clear_active_connection_state(f"连接失败: {exc}")
else:
set_state(is_connecting=False, pending_node_id="", last_check_message=f"连接失败: {exc}")
raise
finally:
finish_connection_attempt(token, cancel_event)
set_state(pending_node_id="")
def maintain_valid_nodes(force: bool = False) -> str:
global active_openvpn_process, active_openvpn_node_id, is_connecting
ensure_dirs()
if not maintenance_lock.acquire(blocking=False):
msg = "节点维护任务正在运行,请稍后再试"
set_state(last_check_message=msg)
return msg
with lock:
if is_connecting:
maintenance_lock.release()
msg = "当前已有连接或节点测试任务正在运行,请稍后再试"
set_state(last_check_message=msg)
return msg
is_connecting = True
try:
# A forced refresh must not tear down a healthy tunnel. It only forces
# the node-pool maintenance path below.
if not active_openvpn_running():
ui_cfg = load_ui_config()
routing_mode = ui_cfg.get("routing_mode", "auto")
connection_enabled = ui_cfg.get("connection_enabled", True)
if connection_enabled:
if routing_mode == "fixed_ip":
reconnect_fixed_node_if_needed(ui_cfg)
else:
has_active_id = False
with lock:
if active_openvpn_node_id:
has_active_id = True
stop_active_openvpn()
if has_active_id:
print("[维护线程] 检测到当前 OpenVPN 进程已意外退出,准备自动切换节点", flush=True)
is_connecting = False
auto_switch_node()
is_connecting = True
try:
set_state(is_connecting=True, last_check_message="正在拉取最新的免费 VPN 节点列表...")
candidates = fetch_candidates()
except Exception as exc:
vpn_utils.check_and_fix_dns()
diag_msg = str(exc)
if not any(token in diag_msg for token in ["[ERR_", "错误代码"]):
err_code, raw_diag = vpn_utils.diagnose_api_failure(API_URL)
diag_msg = f"[错误代码 {err_code}] 获取节点失败: {exc} | 诊断结果: {raw_diag}"
set_state(last_fetch_at=time.time(), last_fetch_status="error", last_fetch_message=diag_msg)
candidates = []
if not candidates:
return "没有拉取到新节点"
with lock:
current_nodes = read_nodes()
current_by_id = {
str(n.get("id")): n
for n in current_nodes
if n.get("id")
}
active_node = None
if active_openvpn_node_id:
active_node = next((n for n in current_nodes if n.get("id") == active_openvpn_node_id), None)
merged: list[dict[str, Any]] = []
seen_ids: set[str] = set()
if active_node:
merged.append(active_node)
seen_ids.add(active_node["id"])
for cand in candidates:
if cand["id"] not in seen_ids:
previous = current_by_id.get(str(cand["id"]))
if previous:
for key in [
"probe_status",
"probe_message",
"latency_ms",
"probed_at",
"owner",
"asn",
"as_name",
"location",
"ip_type",
"quality",
"is_proxy",
"is_hosting",
"is_mobile",
"ip_type_reason",
]:
if previous.get(key) not in (None, ""):
cand[key] = previous.get(key)
merged.append(cand)
seen_ids.add(cand["id"])
if len(merged) > 1000:
merged = merged[:1000]
for n in merged:
config_path = Path(n["config_file"])
if not config_path.exists():
try:
config_path.write_text(n["config_text"], encoding="utf-8")
except Exception:
pass
write_json(NODES_FILE, merged)
ip_enrichment_wakeup.set()
initial_tested_ids: set[str] = set()
fast_results: list[dict[str, Any]] = []
systemic_probe_failure = ""
ui_cfg = load_ui_config()
should_fast_connect = (
ui_cfg.get("connection_enabled", True)
and ui_cfg.get("routing_mode", "auto") != "fixed_ip"
and not active_openvpn_running()
)
if should_fast_connect:
with lock:
current_nodes = read_nodes()
fast_candidates = [
n for n in current_nodes
if not n.get("active") and n.get("probe_status") != "unavailable"
]
fast_candidates = apply_routing_filters(fast_candidates, ui_cfg, include_unknown_ip_type=True)
fast_candidates.sort(key=probe_priority_key)
fast_test_ids = [
n["id"] for n in fast_candidates
if n.get("id")
][:INITIAL_CONNECT_TEST_LIMIT]
if fast_test_ids:
msg = f"首次快速连接模式:优先测试 {len(fast_test_ids)} 个高优先级节点,发现可用节点后立即连接"
print(f"[快速首连] {msg}", flush=True)
log_to_json("INFO", "Main", msg)
set_state(is_connecting=True, last_check_message=msg)
fast_results = test_multiple_nodes(fast_test_ids, target_available=TARGET_VALID_NODES)
systemic_probe_failure = next(
(
str(result.get("probe_message") or "")
for result in fast_results
if is_systemic_probe_failure(result.get("probe_message"))
),
"",
)
initial_tested_ids = {
str(result.get("id") or "")
for result in fast_results
if result.get("id")
}
with lock:
fast_nodes = read_nodes()
available_candidates = [
n for n in fast_nodes
if n.get("probe_status") == "available" and not n.get("active")
]
available_candidates = apply_routing_filters(available_candidates, ui_cfg)
if available_candidates:
is_connecting = False
set_state(is_connecting=False, last_check_message="快速首连已找到可用节点,正在建立连接...")
auto_switch_node()
if active_openvpn_running():
valid_nodes_count = len([n for n in read_nodes() if n.get("probe_status") == "available"])
message = f"Fetched {len(candidates)} nodes. Fast-tested {len(fast_results)} nodes and connected."
set_state(
last_check_at=time.time(),
last_check_message=message,
active_openvpn_node_id=active_openvpn_node_id,
valid_nodes=valid_nodes_count,
)
return message
is_connecting = True
tested_results: list[dict[str, Any]] = []
if systemic_probe_failure:
msg = f"已跳过本轮剩余节点检测,系统级故障需要先处理: {systemic_probe_failure}"
print(f"[周期检测] {msg}", flush=True)
log_to_json("ERROR", "VPN", msg)
set_state(last_check_message=msg)
else:
# Test remaining non-active nodes from the list
with lock:
current_nodes = read_nodes()
to_test = [
n for n in current_nodes
if not n.get("active") and n.get("id") not in initial_tested_ids
]
to_test = apply_routing_filters(to_test, ui_cfg, include_unknown_ip_type=True)
to_test.sort(key=probe_priority_key)
to_test_ids = [n["id"] for n in to_test]
msg = f"开始对列表中所有候选节点进行周期连通性与延迟测试,待检测节点共 {len(to_test_ids)} 个"
print(f"[周期检测] {msg}", flush=True)
log_to_json("INFO", "Main", msg)
set_state(is_connecting=True, last_check_message="正在并发检测所有节点可用性...")
tested_results = test_multiple_nodes(to_test_ids, target_available=TARGET_VALID_NODES)
is_connecting = False
with lock:
merged = read_nodes()
# Identify available, unavailable, and active nodes
available_nodes = [n["id"] for n in merged if n.get("probe_status") == "available"]
unavailable_nodes = [n["id"] for n in merged if n.get("probe_status") == "unavailable"]
active_node = next((n["id"] for n in merged if n.get("active")), "无")
status_report = (
f"周期节点检测完成。实时同步状态: 获取到候选节点共 {len(merged)} 个。 "
f"其中【可用节点】{len(available_nodes)} 个: {available_nodes[:15]}...; "
f"【不可用节点】{len(unavailable_nodes)} 个; "
f"当前【正在正常运行的活动连接节点】为: {active_node}。"
)
print(f"[周期检测] {status_report}", flush=True)
log_to_json("INFO", "Main", status_report)
if active_node != "无" and not active_openvpn_running():
warn_msg = f"[诊断警告] 活动节点 {active_node} 被标记为活动状态,但 OpenVPN 进程实际并未正常运行!"
print(warn_msg, flush=True)
log_to_json("WARNING", "Main", warn_msg)
if not active_openvpn_running():
ui_cfg = load_ui_config()
connection_enabled = ui_cfg.get("connection_enabled", True)
if connection_enabled:
routing_mode = ui_cfg.get("routing_mode", "auto")
if routing_mode != "fixed_ip":
available_candidates = [n for n in merged if n.get("probe_status") == "available"]
available_candidates = apply_routing_filters(available_candidates, ui_cfg)
if available_candidates:
auto_switch_node()
valid_nodes_count = len([n for n in merged if n.get("probe_status") == "available"])
total_tested = len(fast_results) + len(tested_results)
message = f"Fetched {len(candidates)} nodes. Tested {total_tested} prioritized non-active nodes."
set_state(
last_check_at=time.time(),
last_check_message=message,
active_openvpn_node_id=active_openvpn_node_id,
valid_nodes=valid_nodes_count,
)
return message
except Exception as e:
raise e
finally:
is_connecting = False
maintenance_lock.release()
def collector_loop() -> None:
global last_collector_heartbeat
while True:
last_collector_heartbeat = time.time()
success = False
try:
print("[守护线程] 开始执行节点拉取与可用性检测周期任务...", flush=True)
log_to_json("INFO", "Main", "开始执行节点拉取与可用性检测周期任务...")
res = maintain_valid_nodes(force=False)
if "没有拉取到新节点" not in res:
success = True
log_to_json("INFO", "Main", f"周期同步与检测任务完成,结果: {res}")
except Exception as exc:
err_msg = f"周期节点同步任务执行异常: {exc}"
print(f"[错误] {err_msg}", flush=True)
log_to_json("ERROR", "Main", err_msg)
set_state(last_check_at=time.time(), last_check_message=f"check error: {exc}")
if not active_openvpn_running() and not success:
sleep_time = 30
else:
sleep_time = CHECK_INTERVAL_SECONDS
time.sleep(sleep_time)
LOGIN_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AimiliVPN - 安全登录</title>
<style>
:root {
--bg-dark: #090d16;
--bg-surface: rgba(15, 23, 42, 0.96);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--primary: #6366f1;
--primary-gradient: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
--primary-hover: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);
--success: #10b981;
--danger: #f43f5e;
}
body {
margin: 0;
padding: 0;
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%),
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.08) 0px, transparent 50%);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 24px;
box-sizing: border-box;
}
.login-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 40px 32px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
text-align: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.brand-logo {
width: 64px;
height: 64px;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px auto;
color: var(--primary);
position: relative;
}
.brand-logo::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 16px;
border: 1px solid var(--success);
opacity: 0.5;
animation: ripple 2s infinite ease-out;
}
@keyframes ripple {
0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(1.3); opacity: 0; }
}
.login-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
letter-spacing: 0.5px;
}
.login-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 32px 0;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
margin-left: 4px;
}
.input-wrapper {
position: relative;
}
.input-field {
width: 100%;
height: 48px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0 16px;
box-sizing: border-box;
color: var(--text-primary);
font-family: inherit;
font-size: 15px;
outline: none;
transition: all 0.2s ease;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
background: rgba(15, 23, 42, 0.6);
}
.error-message {
color: var(--danger);
font-size: 13px;
margin-top: 8px;
min-height: 18px;
text-align: left;
margin-left: 4px;
display: none;
}
.login-btn {
width: 100%;
height: 48px;
background: var(--primary-gradient);
border: none;
border-radius: 10px;
color: white;
font-family: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25);
}
.login-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35);
}
.login-btn:active {
transform: translateY(1px);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="brand-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" 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>
</div>
<h2 class="login-title">AimiliVPN</h2>
<p class="login-subtitle">请输入您的管理账号和安全密码以继续</p>
<form id="login_form" onsubmit="handleLogin(event)">
<div class="form-group">
<label class="form-label" for="username">管理账号</label>
<div class="input-wrapper">
<input type="text" id="username" name="username" class="input-field" placeholder="请输入管理账号" required autocomplete="username">
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<label class="form-label" for="password">安全密码</label>
<div class="input-wrapper">
<input type="password" id="password" name="password" class="input-field" placeholder="请输入安全密码" required autocomplete="current-password">
</div>
<div id="error_text" class="error-message"></div>
</div>
<button type="submit" id="submit_btn" class="login-btn">
<span>登录</span>
</button>
</form>
</div>
</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;
const errorText = document.getElementById("error_text");
const submitBtn = document.getElementById("submit_btn");
errorText.style.display = "none";
submitBtn.disabled = true;
submitBtn.querySelector("span").textContent = "正在验证...";
try {
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) {
window.location.reload();
} else {
errorText.textContent = data.error || "账号或密码不正确,请重新输入";
errorText.style.display = "block";
submitBtn.disabled = false;
submitBtn.querySelector("span").textContent = "登录";
}
} catch (err) {
errorText.textContent = err && err.name === "AbortError"
? "登录请求超时,请检查网络后重试"
: "连接服务器失败,请稍后重试";
errorText.style.display = "block";
submitBtn.disabled = false;
submitBtn.querySelector("span").textContent = "登录";
}
}
</script>
</body>
</html>
"""
INDEX_HTML = r"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AimiliVPN 节点池管理系统</title>
<style>
:root {
--bg-dark: #0b0f19;
--bg-surface: rgba(22, 30, 49, 0.94);
--bg-surface-hover: rgba(30, 41, 67, 0.85);
--border-color: rgba(255, 255, 255, 0.08);
--border-color-hover: rgba(99, 102, 241, 0.35);
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--primary: #6366f1;
--primary-gradient: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
--primary-hover: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);
--success: #10b981;
--success-gradient: linear-gradient(135deg, #34d399 0%, #059669 100%);
--danger: #f43f5e;
--danger-gradient: linear-gradient(135deg, #fb7185 0%, #e11d48 100%);
--warning: #f59e0b;
--warning-gradient: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
--active-row-bg: rgba(16, 185, 129, 0.06);
--active-row-border: rgba(16, 185, 129, 0.25);
}
body {
margin: 0;
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%),
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.08) 0px, transparent 50%),
radial-gradient(at 50% 100%, rgba(79, 70, 229, 0.05) 0px, transparent 50%);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
header {
padding: 16px 32px;
background: rgba(11, 15, 25, 0.97);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.brand {
display: flex;
flex-direction: column;
}
h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #a5b4fc 0%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.status {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 10px var(--success);
display: inline-block;
}
.btn-group {
display: flex;
gap: 12px;
}
button, .btn-telegram {
height: 38px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0 16px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
white-space: nowrap;
text-decoration: none;
box-sizing: border-box;
}
button:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.btn-telegram {
background: rgba(43, 162, 223, 0.15);
border: 1px solid rgba(43, 162, 223, 0.3);
color: #2ba2df;
}
.btn-telegram:hover {
background: rgba(43, 162, 223, 0.25);
border-color: rgba(43, 162, 223, 0.5);
color: #2ba2df;
transform: translateY(-1px);
}
.btn-primary {
background: var(--primary-gradient);
color: white;
border: none;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
.btn-primary:hover {
background: var(--primary-hover);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35);
}
.btn-danger {
background: var(--danger-gradient);
color: white;
border: none;
box-shadow: 0 4px 12px rgba(244, 63, 94, 0.2);
}
.btn-danger:hover {
opacity: 0.95;
box-shadow: 0 6px 16px rgba(244, 63, 94, 0.35);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
main {
padding: 24px 32px;
max-width: 1400px;
margin: 0 auto;
}
.active-card {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(79, 70, 229, 0.04) 100%);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 16px;
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.12);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
}
.active-card-info {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.active-card-details {
display: flex;
flex-direction: column;
gap: 6px;
}
.active-card-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #a5b4fc;
display: flex;
align-items: center;
gap: 8px;
}
.active-card-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.active-card-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--text-secondary);
flex-wrap: wrap;
}
.active-card-meta span strong {
color: var(--text-primary);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
}
.stat:hover {
background: var(--bg-surface-hover);
border-color: var(--border-color-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.1);
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat strong {
font-size: 32px;
font-weight: 700;
display: block;
margin-bottom: 4px;
background: linear-gradient(135deg, #ffffff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat span {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.stat-icon-wrapper {
width: 44px;
height: 44px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.stat-icon {
width: 22px;
height: 22px;
color: var(--primary);
}
.stat:nth-child(2) .stat-icon { color: var(--warning); }
.stat:nth-child(3) .stat-icon { color: var(--success); }
/* New style additions */
.header-badge-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
height: 24px;
box-sizing: border-box;
}
.header-badge-link:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--border-color-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
.flex-row-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.flex-row-container > * {
flex: 1;
min-width: 320px;
margin-bottom: 0 !important;
}
.vps-recommend-tab {
position: fixed;
right: 0;
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;
border-radius: 8px 0 0 8px;
padding: 16px 6px;
color: white;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
text-align: center;
cursor: pointer;
z-index: 999;
box-shadow: -4px 0 20px rgba(99, 102, 241, 0.3);
transition: all 0.3s ease;
writing-mode: vertical-rl;
text-orientation: mixed;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.vps-recommend-tab:hover {
padding-right: 10px;
box-shadow: -4px 0 25px rgba(99, 102, 241, 0.5);
}
.vps-links {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 576px) {
.vps-links {
grid-template-columns: 1fr;
}
}
.vps-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
justify-content: space-between;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.vps-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(99, 102, 241, 0.3);
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.1);
}
.vps-tag {
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
width: fit-content;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tag-normal {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.2);
}
.tag-premium {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.vps-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
flex: 1;
}
.vps-btn {
align-self: stretch;
text-decoration: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s ease;
text-align: center;
}
.vps-item:hover .vps-btn {
background: var(--primary-gradient);
border-color: transparent;
color: white;
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.2);
}
.vps-footer {
border-top: 1px dashed rgba(255, 255, 255, 0.08);
padding-top: 12px;
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.forum-link {
color: #818cf8;
font-weight: 700;
text-decoration: none;
transition: color 0.2s ease;
}
.forum-link:hover {
color: #a5b4fc;
text-decoration: underline;
}
.toolbar {
position: relative;
z-index: 50;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.toolbar select {
width: 180px;
height: 42px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0 12px;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
transition: all 0.2s ease;
cursor: pointer;
}
.toolbar select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
background: #0f172a;
}
.toolbar > input {
flex: 1;
min-width: 250px;
height: 42px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0 16px;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
transition: all 0.2s ease;
}
.toolbar > input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
background: rgba(15, 23, 42, 0.8);
}
.country-filter {
position: relative;
width: 220px;
flex: 0 0 220px;
}
.country-filter-button {
width: 100%;
height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.country-filter-button:hover,
.country-filter-button[aria-expanded="true"] {
border-color: var(--primary);
background: rgba(15, 23, 42, 0.8);
}
.country-filter-button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.country-filter-chevron {
width: 16px;
height: 16px;
flex: 0 0 16px;
transition: transform 0.2s ease;
}
.country-filter-button[aria-expanded="true"] .country-filter-chevron {
transform: rotate(180deg);
}
.country-filter-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 1000;
width: min(320px, calc(100vw - 40px));
max-height: 360px;
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(15, 23, 42, 0.98);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
.country-filter-panel[hidden] {
display: none;
}
.country-filter-options {
padding: 6px;
overflow-y: auto;
}
.country-option {
position: relative;
min-height: 40px;
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
}
.country-option:hover {
background: rgba(255, 255, 255, 0.06);
}
.country-option-input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.country-option-box {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.7);
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
color: white;
font-size: 12px;
line-height: 1;
}
.country-option-input:checked + .country-option-box {
border-color: var(--primary);
background: var(--primary);
}
.country-option-input:checked + .country-option-box::after {
content: "✓";
}
.country-option-input:focus-visible + .country-option-box {
outline: 2px solid #a5b4fc;
outline-offset: 2px;
}
.country-option-flag {
font-size: 18px;
line-height: 1;
text-align: center;
}
.country-option-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.country-option-count {
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.country-filter-footer {
padding: 8px;
border-top: 1px solid var(--border-color);
}
.country-filter-clear {
width: 100%;
min-height: 36px;
border: 0;
border-radius: 6px;
background: transparent;
color: #a5b4fc;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.country-filter-clear:hover,
.country-filter-clear:focus-visible {
background: rgba(99, 102, 241, 0.12);
outline: none;
}
.table-wrapper {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
text-align: left;
table-layout: fixed;
}
th, td {
padding: 14px 20px;
border-bottom: 1px solid var(--border-color);
font-size: 14px;
}
th {
background: rgba(17, 24, 39, 0.4);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tr {
transition: background 0.2s ease;
}
tr:hover {
background: rgba(255, 255, 255, 0.015);
}
.active-row {
background: var(--active-row-bg) !important;
outline: 2px solid var(--success) !important;
outline-offset: -2px;
position: relative;
z-index: 5;
}
.active-row td {
border-bottom: 1px solid var(--active-row-border);
border-top: 1px solid var(--active-row-border);
}
.badge {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid transparent;
}
.badge-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.5s infinite;
display: inline-block;
}
@keyframes pulse {
0% { transform: scale(0.9); opacity: 1; }
50% { transform: scale(1.6); opacity: 0.4; }
100% { transform: scale(0.9); opacity: 1; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.available {
background: rgba(16, 185, 129, 0.1);
color: #34d399;
border-color: rgba(16, 185, 129, 0.2);
}
.unavailable {
background: rgba(244, 63, 94, 0.1);
color: #fb7185;
border-color: rgba(244, 63, 94, 0.2);
}
.not_checked {
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
border-color: rgba(245, 158, 11, 0.2);
}
.testing {
background: rgba(59, 130, 246, 0.12);
color: #93c5fd;
border-color: rgba(59, 130, 246, 0.24);
}
.current-badge {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
border-color: rgba(99, 102, 241, 0.3);
}
.table-actions {
display: flex;
gap: 8px;
align-items: center;
white-space: nowrap;
}
.connect-btn {
background: transparent;
color: #818cf8;
border: 1px solid rgba(99, 102, 241, 0.4);
border-radius: 6px;
padding: 0 12px;
height: 30px;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
cursor: pointer;
}
.connect-btn:hover:not(:disabled) {
background: var(--primary-gradient);
color: white;
border-color: transparent;
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.3);
}
.connect-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.test-btn {
background: transparent;
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.4);
border-radius: 6px;
padding: 0 12px;
height: 30px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.test-btn:hover:not(:disabled) {
background: var(--success-gradient);
color: white;
border-color: transparent;
box-shadow: 0 4px 10px rgba(16, 185, 129, 0.3);
}
.test-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mono {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 13px;
color: #e2e8f0;
}
.latency-val {
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.latency-good {
background: rgba(16, 185, 129, 0.1);
color: #34d399;
}
.latency-medium {
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
}
.latency-poor {
background: rgba(244, 63, 94, 0.1);
color: #fb7185;
}
.latency-estimated {
background: rgba(148, 163, 184, 0.08);
color: var(--text-secondary);
border: 1px dashed rgba(148, 163, 184, 0.35);
font-weight: 500;
}
.latency-source {
margin-left: 4px;
font-size: 10px;
opacity: 0.8;
}
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: flex-start;
padding: 16px 20px;
position: static;
}
.btn-group {
width: 100%;
margin-top: 12px;
gap: 8px;
flex-wrap: wrap;
}
.btn-group > button,
.btn-group > .btn-telegram,
.btn-group > .dropdown {
flex: 1 1 calc(50% - 4px);
min-width: 0;
}
.btn-group .dropdown {
display: flex;
}
.btn-group .dropdown button {
width: 100%;
flex: 1;
}
#github_dropdown {
left: 0;
right: auto;
width: min(280px, calc(100vw - 40px));
min-width: 0;
}
main {
padding: 16px 20px;
}
.active-card {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.active-card button {
width: 100%;
}
button, .btn-telegram {
min-height: 44px;
}
.input-field,
.toolbar select,
.toolbar > input {
font-size: 16px;
}
}
/* Admin dropdown styles */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
margin-top: 6px;
min-width: 140px;
background: rgba(22, 30, 49, 0.99);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
z-index: 1000;
overflow: hidden;
}
.dropdown-content a,
.dropdown-content button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
color: var(--text-primary);
text-decoration: none;
text-align: left;
font-size: 13px;
font-weight: 500;
font-family: inherit;
border: 0;
background: transparent;
box-sizing: border-box;
cursor: pointer;
transition: background 0.2s;
}
.dropdown-content a:hover,
.dropdown-content button:hover:not(:disabled),
.dropdown-content a:focus-visible,
.dropdown-content button:focus-visible {
background: rgba(255,255,255,0.08);
outline: none;
}
.dropdown-content button:disabled {
opacity: 0.55;
cursor: wait;
}
.github-dropdown {
min-width: 250px;
padding: 6px;
}
.version-current {
padding: 9px 10px 10px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.version-current-label {
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
}
.version-current-meta {
margin-top: 3px;
color: var(--text-secondary);
font-size: 11px;
}
.update-check-status {
min-height: 34px;
margin: 6px 6px 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.update-check-status.available {
border-color: rgba(245, 158, 11, 0.35);
color: #fbbf24;
background: rgba(245, 158, 11, 0.08);
}
.update-check-status.current {
border-color: rgba(16, 185, 129, 0.3);
color: #34d399;
background: rgba(16, 185, 129, 0.08);
}
.update-check-status.error {
border-color: rgba(244, 63, 94, 0.3);
color: #fb7185;
background: rgba(244, 63, 94, 0.08);
}
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow-y: auto;
padding: 24px;
box-sizing: border-box;
background-color: rgba(9, 13, 22, 0.92);
align-items: flex-start;
justify-content: center;
}
.modal-content {
background: rgba(22, 30, 49, 0.99);
border: 1px solid var(--border-color);
border-radius: 20px;
width: 90%;
max-width: 480px;
padding: 32px;
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 {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Inputs in settings */
.form-group {
margin-bottom: 20px;
text-align: left;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
margin-left: 4px;
}
.input-field {
width: 100%;
height: 40px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0 12px;
box-sizing: border-box;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
background: rgba(15, 23, 42, 0.6);
}
select option {
background-color: #0f172a;
color: #f8fafc;
}
/* Option Card Styles for Proxy/Routing Settings */
.option-group {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 6px;
}
@media (max-width: 480px) {
.option-group {
grid-template-columns: 1fr;
}
}
.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;
text-align: left;
}
.option-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(99, 102, 241, 0.25);
transform: translateY(-1px);
}
.option-card.active {
background: rgba(99, 102, 241, 0.08);
border-color: var(--primary);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.15);
}
.option-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.option-card-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.3;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
</style>
</head>
<body>
<header>
<div class="brand">
<h1>
<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" role="status" aria-live="polite"><span class="status-dot"></span>服务加载中...</div>
</div>
<div class="btn-group">
<div class="dropdown">
<button id="github_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="github_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<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="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
<span id="github_version_label">V2.1.2 正式版</span>
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
</button>
<div id="github_dropdown" class="dropdown-content github-dropdown">
<div class="version-current">
<div id="current_version_label" class="version-current-label">V2.1.2 正式版</div>
<div id="deployment_mode_label" class="version-current-meta">Python 源码部署 · 更新通道:main</div>
</div>
<button id="check_update_btn" type="button" onclick="checkForUpdate(event)">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18.5" /></svg>
检测更新
</button>
<a href="https://github.com/baoweise-bot/aimili-vpngate/tree/main" target="_blank" rel="noopener noreferrer">GitHub main 主分支</a>
<a id="latest_release_link" href="https://github.com/baoweise-bot/aimili-vpngate/releases/latest" target="_blank" rel="noopener noreferrer">下载最新正式版</a>
<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" 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>
<button id="refresh" class="btn-primary" style="background: var(--success-gradient);">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18.5" /></svg>
更新节点
</button>
<div class="dropdown">
<button id="admin_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="admin_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
管理员
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
</button>
<div id="admin_dropdown" class="dropdown-content">
<a href="javascript:void(0)" onclick="openCredentialsModal()">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" 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>
网页安全
</a>
<a href="javascript:void(0)" onclick="openNetworkModal()">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" 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>
代理设置
</a>
<a href="javascript:void(0)" onclick="openGatewayModal()">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" 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>
网关设置
</a>
<a href="javascript:void(0)" onclick="openLogsModal()">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" 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>
日志
</a>
<a href="javascript:void(0)" onclick="logoutAdmin()" style="color: var(--danger); border-top: 1px solid rgba(255,255,255,0.05);">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
退出
</a>
</div>
</div>
</div>
</header>
<main>
<!-- 当前连接活动节点卡片 -->
<section class="active-node-section" id="active_node_card" style="margin-bottom: 24px;">
<!-- Rendered dynamically by render() -->
</section>
<section class="toolbar">
<select id="status_filter">
<option value="all">全部节点</option>
<option value="available">可用节点</option>
<option value="testing">检测中</option>
<option value="unavailable">失效节点</option>
</select>
<div class="country-filter" id="country_filter">
<button
id="country_filter_button"
class="country-filter-button"
type="button"
aria-expanded="false"
aria-controls="country_filter_panel"
>
<span id="country_filter_label">所有国家</span>
<svg class="country-filter-chevron" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div id="country_filter_panel" class="country-filter-panel" role="group" aria-label="国家筛选" hidden>
<div id="country_filter_options" class="country-filter-options"></div>
<div class="country-filter-footer">
<button class="country-filter-clear" type="button" onclick="clearDiscoveryCountries(event)">清空选择</button>
</div>
</div>
</div>
<select id="ip_type_filter">
<option value="">所有IP类型</option>
<option value="residential">住宅IP</option>
<option value="hosting">机房IP</option>
</select>
<button id="btn_favorites" class="toolbar-btn" type="button" onclick="toggleFavoritesView()" style="margin-left: auto; height: 42px; gap: 6px;">
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.907c.961 0 1.371 1.24.588 1.81l-3.97 2.883a1 1 0 00-.364 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.971-2.883a1 1 0 00-1.175 0l-3.97 2.883c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.364-1.118l-3.97-2.883c-.783-.57-.372-1.81.588-1.81h4.906a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
收藏菜单
</button>
</section>
<div id="favorites_panel" style="display: none; background: rgba(22, 30, 49, 0.97); border: 1px solid var(--border-color); border-radius: 16px; padding: 20px; margin-bottom: 20px; animation: modalFadeIn 0.25s ease-out;">
<div style="display: flex; flex-direction: column; gap: 16px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 15px; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 6px;">
⭐ 收藏专属管理面板
</span>
<span style="font-size: 13px; color: var(--text-secondary);">
在这里管理您的收藏节点过滤,以及设置出站连接漂移策略。
</span>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<button id="btn_toggle_fav_routing" type="button" class="toolbar-btn" style="height: 36px; padding: 0 14px; font-size: 13px; border-radius: 6px;" onclick="toggleFavRouting()">
启用仅用收藏出站
</button>
</div>
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px;">
<div style="padding: 10px 14px; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.25); border-radius: 8px; font-size: 12px; color: var(--warning); line-height: 1.5;">
<strong>仅用收藏是强锁定模式。</strong>开启后只会连接收藏节点;如果收藏节点全部不可用,系统不会切换到非收藏节点。
</div>
</div>
</div>
</div>
<div class="table-wrapper">
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 90px;">状态</th>
<th style="width: 220px;">IP 地址 : 端口</th>
<th style="width: 125px;">延迟</th>
<th>物理位置</th>
<th>运营主体 / ISP</th>
<th style="width: 110px;">IP 类型</th>
<th style="width: 230px;">操作</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<!-- 分页控制栏 -->
<div id="pagination_container" class="pagination-container" style="padding: 16px; display: none; justify-content: space-between; align-items: center; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 12px;">
<div style="font-size: 13px; color: var(--text-secondary);">
显示第 <span id="page_start" style="color: var(--text-primary); font-weight:600;">0</span> - <span id="page_end" style="color: var(--text-primary); font-weight:600;">0</span> 条,共 <span id="filtered_count" style="color: var(--text-primary); font-weight:600;">0</span> 条备选节点
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="btn_first_page" class="connect-btn" style="height: 32px; padding: 0 10px;">首页</button>
<button id="btn_prev_page" class="connect-btn" style="height: 32px; padding: 0 10px;">上一页</button>
<span style="font-size: 13px; color: var(--text-secondary); margin: 0 8px;">
页码 <strong id="current_page_val" style="color: var(--primary);">1</strong> / <strong id="total_pages_val">1</strong>
</span>
<button id="btn_next_page" class="connect-btn" style="height: 32px; padding: 0 10px;">下一页</button>
<button id="btn_last_page" class="connect-btn" style="height: 32px; padding: 0 10px;">尾页</button>
</div>
</div>
</div>
<!-- Credentials Modal (网页安全设置) -->
<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 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" 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" 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;">
<label class="form-label" for="cred_username">管理账号</label>
<input type="text" id="cred_username" class="input-field" required placeholder="请输入管理账号">
</div>
<div class="form-group" style="margin-bottom: 12px;">
<label class="form-label" for="cred_password">安全密码</label>
<input type="password" id="cred_password" class="input-field" placeholder="留空则保留当前密码">
</div>
<div class="form-group" style="margin-bottom: 12px;">
<label class="form-label" for="cred_port">网页管理端口</label>
<input type="number" id="cred_port" class="input-field" required min="1" max="65535" placeholder="8787">
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label class="form-label" for="cred_suffix">登录安全后缀 (仅字母和数字)</label>
<input type="text" id="cred_suffix" class="input-field" required pattern="[A-Za-z0-9]+" placeholder="EJsW2EeBo9lY">
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button type="button" onclick="closeCredentialsModal()" style="height: 40px; padding: 0 16px; font-weight: 600; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); cursor: pointer;">取消</button>
<button type="submit" id="credentials_submit_btn" class="btn-primary" style="height: 40px; padding: 0 20px; font-weight: 600; border-radius: 8px;">保存修改</button>
</div>
</form>
</div>
</div>
<!-- Network Modal (代理及网络设置,包括出站路由) -->
<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 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" 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" 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;">
<label class="form-label" for="net_proxy_port">HTTP/SOCKS5 代理出站端口</label>
<input type="number" id="net_proxy_port" class="input-field" required min="1024" max="65535" placeholder="7928">
</div>
<div style="border-top: 1px dashed rgba(255,255,255,0.08); padding-top: 16px; margin-bottom: 16px;">
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">IP 出站路由模式</label>
<input type="hidden" id="net_routing_mode" value="auto">
<div class="option-group" id="routing_mode_group">
<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>
</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>
</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>
</button>
</div>
</div>
<div id="net_force_country_group" class="form-group" style="margin-bottom: 16px; display: none;">
<label class="form-label" for="net_force_country">锁定国家地区</label>
<select id="net_force_country" class="input-field" style="background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border-color); color: var(--text-primary); outline: none; cursor: pointer; width: 100%; height: 40px; border-radius: 8px; padding: 0 12px;">
<option value="">正在加载节点国家...</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 16px;">
<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">
<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>
</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>
</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>
</button>
</div>
</div>
<div id="net_routing_warning" style="font-size: 12px; color: var(--text-secondary); line-height: 1.4; padding: 8px 12px; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 6px; margin-top: 8px;">
️ <strong>自动配置</strong>:全自动测试并选择最佳IP。在使用过程中,如果当前连接节点没有失效,将不再更换IP;如果当前节点失效,系统将立刻秒级自动漂移到其他最快的可用节点。
</div>
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button type="button" onclick="closeNetworkModal()" style="height: 40px; padding: 0 16px; font-weight: 600; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); cursor: pointer;">取消</button>
<button type="submit" id="network_submit_btn" class="btn-primary" style="height: 40px; padding: 0 20px; font-weight: 600; border-radius: 8px;">保存修改</button>
</div>
</form>
</div>
</div>
<!-- VPS 购买推荐 Modal -->
<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 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" 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>
<div class="vps-links">
<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" 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" rel="noopener noreferrer" class="vps-btn">点击进入官网</a>
</div>
</div>
<div class="vps-footer" style="margin-top: 20px;">
官方技术支持及优质资源交流论坛:<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;">
<div style="font-weight: bold; color: var(--text-primary); margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px; height:16px; color: var(--primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
🎁 捐赠支持项目开发:
</div>
<div style="font-family: monospace; background: rgba(0,0,0,0.2); padding: 8px 12px; border-radius: 6px; margin-top: 6px; word-break: break-all; select-all: true;">
<span style="color: var(--primary); font-weight: bold;">BNB (BSC):</span> 0xB6d78c42CEB0687A31B8cfEBE4b51b6eB8953C17<br>
<span style="color: var(--primary); font-weight: bold;">TRX (TRC20):</span> TSdzCW6JvsrqcppodYjhSrku4mYmDJ9pxf
</div>
</div>
</div>
</div>
<button type="button" class="vps-recommend-tab" onclick="openVpsModal()">VPS购买推荐</button>
<!-- Gateway Modal (网关自检与代理测试) -->
<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 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" 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>
<!-- 服务列表 -->
<div id="gateway_services_list" style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px;">
<div style="text-align: center; color: var(--text-secondary); padding: 20px 0;">
<svg style="animation: spin 1s linear infinite; width: 20px; height: 20px; display: inline-block; margin-bottom: 8px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.2" fill="none"></circle><path d="M4 12a8 8 0 018-8" stroke="currentColor" fill="none"></path></svg>
<div>正在加载系统网关状态...</div>
</div>
</div>
<!-- 分割线 -->
<div style="border-top: 1px dashed rgba(255, 255, 255, 0.08); margin: 20px 0;"></div>
<!-- 本地代理出口检测 -->
<div style="background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<div class="stat-icon-wrapper" style="background: rgba(99, 102, 241, 0.1); border-color: rgba(99, 102, 241, 0.2); width: 36px; height: 36px; border-radius: 8px; flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" class="stat-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" style="color: var(--primary); width: 18px; height: 18px;"><path stroke-linecap="round" stroke-linejoin="round" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071a10.5 10.5 0 0114.14 0M1.414 8.05a16 16 0 0121.172 0" /></svg>
</div>
<div>
<h4 style="margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary);">本地代理出口检测</h4>
<p style="margin: 2px 0 0 0; font-size: 12px; color: var(--text-secondary);">检测 HTTP/SOCKS5 代理出站连通性与 IP</p>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; background: rgba(0, 0, 0, 0.2); border-radius: 8px; padding: 12px; margin-bottom: 12px; flex-wrap: wrap; gap: 10px;">
<div style="font-size: 13px; color: var(--text-secondary);">
测试状态: <span id="proxy_status_badge" class="badge not_checked" style="margin-left: 4px;">未检测</span>
</div>
<div style="font-size: 13px; color: var(--text-secondary); text-align: right;">
出口 IP: <span id="proxy_ip_val" class="mono" style="font-weight: 600; color: var(--text-primary);">-</span>
<span id="proxy_latency_val" style="margin-left: 6px;"></span>
</div>
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button id="btn_test_proxy" class="btn-primary" style="height: 36px; padding: 0 16px; font-size: 13px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
开始检测
</button>
</div>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 20px;">
<button type="button" onclick="closeGatewayModal()" style="height: 38px; padding: 0 20px; font-weight: 600; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); cursor: pointer;">关闭</button>
</div>
</div>
</div>
<!-- Logs Modal (日志监控与分类筛选) -->
<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 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>
<div style="display: flex; align-items: center; gap: 10px; margin-left: auto;">
<label class="form-label" for="log_filter_select" style="margin: 0; font-size: 13px; color: var(--text-secondary);">日志筛选:</label>
<select id="log_filter_select" class="input-field" style="width: 140px; height: 32px; font-size: 12px; border-radius: 6px; padding: 0 8px; background: rgba(255, 255, 255, 0.03);" onchange="filterAndRenderLogs()">
<option value="all">全部日志</option>
<option value="proxy">代理相关 (Proxy)</option>
<option value="vpn">VPN 连接 (VPN)</option>
<option value="system">系统运行 (Main/Route)</option>
</select>
</div>
<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>
<!-- Terminal Log Container -->
<div id="log_terminal_container" style="background: #050811; border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 10px; height: 400px; padding: 16px; overflow-y: auto; font-family: 'JetBrains Mono', Consolas, Courier, monospace; font-size: 12px; line-height: 1.5; text-align: left; white-space: pre-wrap; word-break: break-all; color: #a5b4fc; box-shadow: inset 0 4px 20px rgba(0,0,0,0.8); position: relative; margin-bottom: 20px;">
<div style="color: var(--text-secondary); text-align: center; margin-top: 150px;">
暂无今日运行日志记录。
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; gap: 8px;">
<button type="button" onclick="copyLogContent()" class="btn-primary" style="height: 38px; padding: 0 16px; background: rgba(255,255,255,0.05); color: var(--text-primary); border: 1px solid var(--border-color);">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px; margin-right: 4px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
一键复制
</button>
<button type="button" onclick="exportLogContent()" class="btn-primary" style="height: 38px; padding: 0 16px; background: rgba(255,255,255,0.05); color: var(--text-primary); border: 1px solid var(--border-color);">
<svg xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px; margin-right: 4px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
导出日志
</button>
</div>
<button type="button" onclick="closeLogsModal()" style="height: 38px; padding: 0 20px; font-weight: 600; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); cursor: pointer;">关闭</button>
</div>
</div>
</div>
</main>
<script>
let nodes=[], state={}, testingNodeIds = new Set();
const favoriteRequestIds = new Set();
let disconnectInFlight = false;
let currentPage = 1;
const pageSize = 50;
let currentPageNodes = [];
let selectedDiscoveryCountries = new Set();
let discoveryCountriesInitialized = false;
let discoveryCountriesDirty = false;
let countryFilterSignature = "";
let lastNodesSnapshotSignature = "";
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;
element.innerHTML = html;
renderedHtmlCache.set(element, html);
return true;
}
function isPageVisible() {
return typeof document.hidden !== "boolean" || !document.hidden;
}
const base=p=>(p||"").split(/[\\/]/).pop();
function time(ts){return ts?new Date(ts*1000).toLocaleString():"从未"}
function speed(v){return v?`${(v*8/1000/1000).toFixed(1)} Mbps`:"-"}
const translateQuality = q => {
const dict = {"normal": "普通", "proxy": "代理", "datacenter": "数据中心", "mobile": "移动端"};
return dict[q] || q || "-";
};
const translateIpType = t => {
const dict = {"residential": "住宅 IP", "hosting": "机房 IP", "mobile": "移动网", "proxy": "代理 IP"};
return dict[t] || t || "-";
};
const translateCountry = c => {
const dict = {
"Japan": "日本",
"Korea Republic of": "韩国",
"Korea": "韩国",
"Republic of Korea": "韩国",
"Thailand": "泰国",
"United States": "美国",
"United Kingdom": "英国",
"Russian Federation": "俄罗斯",
"Russian": "俄罗斯",
"Viet Nam": "越南",
"Vietnam": "越南",
"China": "中国",
"Taiwan": "台湾",
"Taiwan Province of China": "台湾",
"Hong Kong": "香港",
"Singapore": "新加坡",
"Malaysia": "马来西亚",
"Indonesia": "印度尼西亚",
"India": "印度",
"Philippines": "菲律宾",
"Australia": "澳大利亚",
"New Zealand": "新西兰",
"Canada": "加拿大",
"Ukraine": "乌克兰",
"France": "法国",
"Germany": "德国",
"Netherlands": "荷兰",
"Sweden": "瑞典",
"Norway": "挪威",
"Spain": "西班牙",
"Turkey": "土耳其",
"South Africa": "南非",
"Brazil": "巴西",
"Argentina": "阿根廷",
"Chile": "智利",
"Mexico": "墨西哥",
"Egypt": "埃及",
"Romania": "罗马尼亚",
"Poland": "波兰",
"Kazakhstan": "哈萨克斯坦",
"Georgia": "格鲁吉亚",
"Mongolia": "蒙古",
"Saudi Arabia": "沙特阿拉伯",
"Iran": "伊朗",
"Iraq": "伊拉克",
"Colombia": "哥伦比亚",
"Cambodia": "柬埔寨",
"Ireland": "爱尔兰",
"Italy": "意大利",
"Switzerland": "瑞士",
"Belgium": "比利时",
"Austria": "奥地利",
"Denmark": "丹麦",
"Finland": "芬兰",
"Portugal": "葡萄牙",
"Greece": "希腊",
"Czech Republic": "捷克",
"Hungary": "匈牙利",
"Israel": "以色列",
"United Arab Emirates": "阿联酋",
"UAE": "阿联酋",
"Macao": "澳门",
"Macau": "澳门",
"Iceland": "冰岛",
"Luxembourg": "卢森堡"
};
return dict[c] || c || "-";
};
const translateStatus = s => {
const dict = {"available": "可用", "unavailable": "不可用", "testing": "检测中", "not_checked": "待检测"};
return dict[s] || s || "待检测";
};
function getLatencyClass(ms) {
if (!ms) return '';
if (ms < 50) return 'latency-good';
if (ms < 150) return 'latency-medium';
return 'latency-poor';
}
function countryFlag(countryShort) {
const code = String(countryShort || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return "";
return Array.from(code)
.map(char => String.fromCodePoint(char.charCodeAt(0) + 127397))
.join("");
}
function nodeLatencyHtml(node) {
const measured = Number(node && node.latency_ms) || 0;
if (measured > 0) {
return `<span class="latency-val ${getLatencyClass(measured)}" title="本机实测延迟">${measured} ms</span>`;
}
const estimated = Number(node && node.ping) || 0;
if (estimated > 0) {
return `<span class="latency-val latency-estimated" title="VPNGate 官方公示值,仅供参考、非本机实测">~${estimated} ms<span class="latency-source">预估</span></span>`;
}
return "-";
}
function syncDiscoveryCountriesFromState() {
if (discoveryCountriesInitialized && discoveryCountriesDirty) return;
const saved = Array.isArray(state.discovery_countries) ? state.discovery_countries : [];
selectedDiscoveryCountries = new Set(
saved
.map(code => String(code || "").trim().toUpperCase())
.filter(code => /^[A-Z]{2}$/.test(code))
);
discoveryCountriesInitialized = true;
}
function updateCountryFilterLabel() {
const label = $("country_filter_label");
if (!label) return;
const count = selectedDiscoveryCountries.size;
label.textContent = count ? `已选 ${count} 个国家` : "所有国家";
}
function updateCountryFilter() {
syncDiscoveryCountriesFromState();
const countries = new Map();
nodes.forEach(node => {
if (!node) return;
const code = String(node.country_short || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return;
const current = countries.get(code) || {
code,
name: translateCountry(node.country) || code,
count: 0,
};
current.count += 1;
countries.set(code, current);
});
selectedDiscoveryCountries.forEach(code => {
if (!countries.has(code)) {
countries.set(code, { code, name: code, count: 0 });
}
});
const options = Array.from(countries.values()).sort((a, b) =>
a.name.localeCompare(b.name, "zh-CN") || a.code.localeCompare(b.code)
);
const signature = JSON.stringify(options);
if (signature !== countryFilterSignature) {
const container = $("country_filter_options");
container.innerHTML = options.length
? options.map(item => `
<label class="country-option">
<input
class="country-option-input"
type="checkbox"
value="${esc(item.code)}"
${selectedDiscoveryCountries.has(item.code) ? "checked" : ""}
onchange="toggleDiscoveryCountry(this)"
>
<span class="country-option-box" aria-hidden="true"></span>
<span class="country-option-flag" aria-hidden="true">${esc(countryFlag(item.code))}</span>
<span class="country-option-name">${esc(item.name)}</span>
<span class="country-option-count">${item.count}</span>
</label>
`).join("")
: '<div style="padding:12px; color:var(--text-secondary); font-size:13px;">暂无国家数据</div>';
countryFilterSignature = signature;
}
document.querySelectorAll(".country-option-input").forEach(input => {
input.checked = selectedDiscoveryCountries.has(input.value);
});
updateCountryFilterLabel();
}
function setCountryFilterOpen(open) {
const button = $("country_filter_button");
const panel = $("country_filter_panel");
if (!button || !panel) return;
button.setAttribute("aria-expanded", open ? "true" : "false");
panel.hidden = !open;
}
function toggleDiscoveryCountry(input) {
const code = String(input.value || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return;
if (input.checked) selectedDiscoveryCountries.add(code);
else selectedDiscoveryCountries.delete(code);
discoveryCountriesDirty = true;
currentPage = 1;
updateCountryFilterLabel();
render();
}
function clearDiscoveryCountries(event) {
if (event) event.stopPropagation();
selectedDiscoveryCountries.clear();
discoveryCountriesDirty = true;
document.querySelectorAll(".country-option-input").forEach(input => {
input.checked = false;
});
currentPage = 1;
updateCountryFilterLabel();
render();
}
function getFilteredNodes() {
const selectedIpType = $("ip_type_filter").value;
const selectedStatus = $("status_filter").value;
return nodes.filter(n => {
if (!n) return false;
const countryCode = String(n.country_short || "").trim().toUpperCase();
if (selectedDiscoveryCountries.size && !selectedDiscoveryCountries.has(countryCode)) {
return false;
}
if (selectedIpType) {
if (selectedIpType === "residential" && !["residential", "mobile"].includes(n.ip_type)) {
return false;
}
if (selectedIpType === "hosting" && n.ip_type !== "hosting") {
return false;
}
}
if (selectedStatus === "available" && n.probe_status !== "available" && !n.active) {
return false;
}
if (selectedStatus === "testing" && n.probe_status !== "testing") {
return false;
}
if (selectedStatus === "unavailable" && (n.probe_status !== "unavailable" || n.active)) {
return false;
}
const favoriteIds = Array.isArray(state.favorite_node_ids) ? state.favorite_node_ids : [];
if (showFavoritesOnly && !favoriteIds.includes(n.id)) {
return false;
}
return true;
});
}
function stableSortNodes() {
nodes.sort((a, b) => {
if (!a || !b) return 0;
const aScore = a.score || 0;
const bScore = b.score || 0;
if (bScore !== aScore) {
return bScore - aScore;
}
const aId = a.id || "";
const bId = b.id || "";
return aId.localeCompare(bId);
});
}
function render(){
const versionLabel = state.app_version_label || "V2.1.2 正式版";
if ($("github_version_label")) $("github_version_label").textContent = versionLabel;
if ($("current_version_label")) $("current_version_label").textContent = versionLabel;
if ($("deployment_mode_label")) {
const modeLabel = state.deployment_mode_label || "Python 源码";
$("deployment_mode_label").textContent = `${modeLabel}部署 · 更新通道:main`;
}
const activeNodeId = state.active_openvpn_node_id;
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
// Render separated Active Node Card
const activeCardContainer = $("active_node_card");
let activeCardHtml = "";
if (state.is_connecting && !activeNode) {
const busyTitle = state.maintenance_running ? "正在更新节点" : "正在连接";
const busyLatency = state.maintenance_running ? "节点检测中" : (state.active_node_latency || "正在连接...");
const busyMessage = state.last_check_message || (state.maintenance_running ? "正在后台拉取并检测节点,已完成的结果会实时显示在下方列表。" : "正在与 VPN 节点建立加密隧道,请稍候...");
activeCardHtml = `
<div class="active-card" style="background: var(--bg-surface); border-color: var(--warning); box-shadow: 0 0 15px rgba(245, 158, 11, 0.15);">
<div class="active-card-info">
<div class="stat-icon-wrapper" style="background: rgba(245, 158, 11, 0.15); border-color: rgba(245, 158, 11, 0.3); width: 48px; height: 48px; border-radius: 12px;">
<svg xmlns="http://www.w3.org/2000/svg" class="stat-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" style="color: #f59e0b; width: 24px; height: 24px; animation: spin 2s linear infinite;"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18" /></svg>
</div>
<div class="active-card-details">
<div class="active-card-title" style="color: var(--text-primary);">
<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b; border-color: rgba(245, 158, 11, 0.3);"><span class="badge-pulse" style="background: #f59e0b;"></span>${esc(busyTitle)}</span>
<strong>${esc(busyLatency)}</strong>
</div>
<div class="active-card-meta" style="margin-top: 4px;">
${esc(busyMessage)}
</div>
</div>
</div>
</div>
`;
} else if (activeNode) {
const latencyText = nodeLatencyHtml(activeNode);
const displayLocation = activeNode.location || translateCountry(activeNode.country) || "-";
const activeFlag = countryFlag(activeNode.country_short);
activeCardHtml = `
<div class="active-card">
<div class="active-card-info">
<div class="stat-icon-wrapper" style="background: rgba(16, 185, 129, 0.15); border-color: rgba(16, 185, 129, 0.3); width: 48px; height: 48px; border-radius: 12px;">
<svg xmlns="http://www.w3.org/2000/svg" class="stat-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" style="color: #34d399; width: 24px; height: 24px;"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<div class="active-card-details">
<div class="active-card-title">
<span class="badge available"><span class="badge-pulse"></span>已连接</span>
<strong>${esc(translateCountry(activeNode.country))} 节点</strong>
</div>
<div class="active-card-value mono" style="font-size: 20px; margin-top: 2px;">
${esc(activeNode.ip || activeNode.remote_host)}:${activeNode.remote_port || ""}
</div>
<div class="active-card-meta" style="margin-top: 4px;">
<span>物理位置: <strong>${activeFlag ? `${esc(activeFlag)} ` : ""}${esc(displayLocation)}</strong></span>
<span style="margin-left: 12px;">延时: <strong>${latencyText}</strong></span>
<span style="margin-left: 12px;">运营主体: <strong>${esc(activeNode.owner || activeNode.as_name || "-")}</strong></span>
<span style="margin-left: 12px;">IP 类型: <strong>${esc(translateIpType(activeNode.ip_type))}</strong></span>
</div>
</div>
</div>
<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>
</div>
`;
} else {
activeCardHtml = `
<div class="active-card" style="background: var(--bg-surface); border-color: var(--border-color); box-shadow: none;">
<div class="active-card-info">
<div class="stat-icon-wrapper" style="background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.2); width: 48px; height: 48px; border-radius: 12px;">
<svg xmlns="http://www.w3.org/2000/svg" class="stat-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" style="color: var(--danger); width: 24px; height: 24px;"><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>
</div>
<div class="active-card-details">
<div class="active-card-title" style="color: var(--text-secondary);">
<span class="badge unavailable" style="padding: 2px 8px;">未连接</span> 当前未连接 VPN 节点
</div>
<div class="active-card-meta" style="margin-top: 4px;">
在下方列表中选择一个可用备用节点并点击 “切换” 按钮开始连接。
</div>
</div>
</div>
</div>
`;
}
setHtmlIfChanged(activeCardContainer, activeCardHtml);
const shown = getFilteredNodes();
if ($("total")) $("total").textContent = nodes.length;
if ($("target")) $("target").textContent = state.target_valid_nodes || 3;
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))} (${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 代理本地接口:${esc(localProxy)} | 活动节点:${activeNodeInfo} | 状态:${esc(statusMessage)}`; }
// Update proxy test status card based on background checks
const pBadge = $("proxy_status_badge");
const pIpVal = $("proxy_ip_val");
const pLatVal = $("proxy_latency_val");
const pBtn = $("btn_test_proxy");
if (state.is_connecting) {
pBadge.className = "badge";
pBadge.style.background = "rgba(245, 158, 11, 0.15)";
pBadge.style.color = "#f59e0b";
pBadge.style.borderColor = "rgba(245, 158, 11, 0.3)";
pBadge.innerHTML = `<span class="badge-pulse" style="background: #f59e0b;"></span>正在连接`;
pIpVal.textContent = state.active_node_latency || "正在连接...";
pLatVal.innerHTML = `<span style="color: var(--text-secondary); font-size: 12px;">${esc(state.last_check_message || "正在与 VPN 节点建立加密隧道,请稍候...")}</span>`;
pBtn.disabled = true;
pBtn.style.opacity = "0.5";
pBtn.style.cursor = "not-allowed";
} else {
pBtn.disabled = false;
pBtn.style.opacity = "";
pBtn.style.cursor = "";
pBadge.style.background = "";
pBadge.style.color = "";
pBadge.style.borderColor = "";
if (state.proxy_ok !== undefined) {
if (state.proxy_ok) {
pBadge.className = "badge available";
pBadge.textContent = "可用";
pIpVal.textContent = state.proxy_ip || "-";
const latencyClass = getLatencyClass(state.proxy_latency_ms);
pLatVal.innerHTML = `<span class="latency-val ${latencyClass}" style="margin-left:8px;">${state.proxy_latency_ms} ms</span>`;
} else {
pBadge.className = "badge unavailable";
pBadge.textContent = "不可用";
pIpVal.textContent = "-";
pLatVal.innerHTML = `<span class="latency-val latency-poor" style="margin-left:8px; font-size:11px; max-width: 450px; display: inline-block; white-space: normal; line-height: 1.4; text-align: left;" title="${esc(state.proxy_error)}">${esc(state.proxy_error || "连接失败")}</span>`;
}
} else {
pBadge.className = "badge not_checked";
pBadge.textContent = "未检测";
pIpVal.textContent = "-";
if (state.last_check_message) {
pLatVal.innerHTML = `<span style="color: var(--text-secondary); font-size: 12px;">${esc(state.last_check_message)}</span>`;
} else {
pLatVal.innerHTML = "";
}
}
}
updateFavPanelUI();
// Pagination calculation
const totalPages = Math.ceil(shown.length / pageSize) || 1;
const paginationContainer = $("pagination_container");
if (paginationContainer) paginationContainer.style.display = totalPages > 1 ? "flex" : "none";
if (currentPage > totalPages) currentPage = totalPages;
if (currentPage < 1) currentPage = 1;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, shown.length);
currentPageNodes = shown.slice(startIndex, endIndex);
// Render table rows
let rowsHtml = "";
if (currentPageNodes.length === 0) {
rowsHtml = `<tr><td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 40px 0;">未找到符合过滤条件的备选节点。</td></tr>`;
} else {
rowsHtml = currentPageNodes.map(n=>{
if (!n) return '';
const isCurrentlyActive = activeNode && n.id === activeNode.id;
const isPending = Boolean(state.is_connecting && state.pending_node_id === n.id);
const rowClass = isCurrentlyActive ? 'class="active-row"' : '';
const badgeClass = isCurrentlyActive ? 'available' : (isPending ? 'testing' : (n.probe_status || 'not_checked'));
const badgeText = isCurrentlyActive ? '<span class="badge-pulse"></span>已连接' : (isPending ? '<span class="badge-pulse"></span>切换中' : translateStatus(n.probe_status));
const latencyText = nodeLatencyHtml(n);
const displayLocation = n.location || translateCountry(n.country) || "-";
const flag = countryFlag(n.country_short);
const isTesting = testingNodeIds.has(n.id) || n.probe_status === "testing";
const testSpinner = `<svg style="animation: spin 1s linear infinite; width: 12px; height: 12px; display: inline-block; margin-right: 4px; vertical-align: middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.2" fill="none"></circle><path d="M4 12a8 8 0 018-8" stroke="currentColor" fill="none"></path></svg>`;
const testBtnText = isTesting ? `${testSpinner}检测中` : '检测';
const testBtn = `<button class="test-btn" data-node-id="${esc(n.id)}" ${isTesting ? 'disabled' : ''} onclick="testNode(this, '${esc(n.id)}', event)">${testBtnText}</button>`;
// Connect button is disabled if probe status is "unavailable" and not already active, or if we are already connecting
// Connect button is disabled if probe status is "unavailable" and not already active, or if we are already connecting
const isUnavailable = n.probe_status === "unavailable";
const connectBtn = isCurrentlyActive
? `<button class="connect-btn" disabled style="background: var(--success-gradient); color: white; cursor: default; opacity: 1;">已连接</button>`
: `<button class="connect-btn" ${(isUnavailable || isTesting || state.is_connecting) ? 'disabled style="opacity:0.3; cursor:not-allowed;"' : ''} onclick="connectNode('${esc(n.id)}')">${isPending ? '切换中' : '切换'}</button>`;
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" ${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>
<td class="mono" style="white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.ip||n.remote_host)}:${n.remote_port||""}">${esc(n.ip||n.remote_host)}:${n.remote_port||""}</td>
<td style="white-space: nowrap;">${latencyText}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(displayLocation)}">${flag ? `<span aria-hidden="true">${esc(flag)}</span> ` : ""}${esc(displayLocation)}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.owner||n.as_name||"-")}">${esc(n.owner||n.as_name||"-")}</td>
<td style="white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis;" title="${esc(translateIpType(n.ip_type))}">${esc(translateIpType(n.ip_type))}</td>
<td>
<div class="table-actions">
${testBtn}
${favBtn}
${connectBtn}
</div>
</td>
</tr>`;
}).join("");
}
setHtmlIfChanged($("rows"), rowsHtml);
// Render pagination controls
$("page_start").textContent = shown.length > 0 ? startIndex + 1 : 0;
$("page_end").textContent = endIndex;
$("filtered_count").textContent = shown.length;
$("current_page_val").textContent = currentPage;
$("total_pages_val").textContent = totalPages;
$("btn_first_page").disabled = currentPage === 1;
$("btn_prev_page").disabled = currentPage === 1;
$("btn_next_page").disabled = currentPage === totalPages;
$("btn_last_page").disabled = currentPage === totalPages;
}
// Hook up page buttons events
$("btn_first_page").onclick = () => { currentPage = 1; render(); };
$("btn_prev_page").onclick = () => { if (currentPage > 1) { currentPage--; render(); } };
$("btn_next_page").onclick = () => {
const shown = getFilteredNodes();
const totalPages = Math.ceil(shown.length / pageSize) || 1;
if (currentPage < totalPages) { currentPage++; render(); }
};
$("btn_last_page").onclick = () => {
const shown = getFilteredNodes();
const totalPages = Math.ceil(shown.length / pageSize) || 1;
currentPage = totalPages;
render();
};
async function testNode(btn, id, event){
if (event) event.stopPropagation();
if (testingNodeIds.has(id)) return;
testingNodeIds.add(id);
render();
try {
const response = await fetchWithTimeout("./api/test_node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id })
}, 45000);
const result = await readJsonResponse(response, "节点检测失败");
if (result.ok && result.node) {
const idx = nodes.findIndex(n => n && n.id === id);
if (idx !== -1) {
nodes[idx] = result.node;
}
}
} catch (e) {
alert("节点检测失败: " + (e.message || "未知错误"));
} finally {
testingNodeIds.delete(id);
render();
}
}
async function toggleFavorite(id, event) {
if (event) event.stopPropagation();
if (favoriteRequestIds.has(id)) return;
favoriteRequestIds.add(id);
render();
try {
const response = await fetchWithTimeout("./api/toggle_favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id })
}, 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();
}
}
let pollInterval = null;
let refreshPollInterval = null;
let refreshPollInFlight = false;
let connectionPollInFlight = false;
let nodesRequestPromise = null;
async function fetchNodesSnapshot() {
if (nodesRequestPromise) return nodesRequestPromise;
const request = (async () => {
const response = await fetchWithTimeout("./api/nodes", { cache: "no-store" }, 20000);
if (!response.ok) throw new Error(`节点状态请求失败 (${response.status})`);
return response.json();
})();
nodesRequestPromise = request;
try {
return await request;
} finally {
if (nodesRequestPromise === request) nodesRequestPromise = null;
}
}
function applyNodesSnapshot(data) {
const nextNodes = Array.isArray(data && data.nodes) ? data.nodes : [];
const nextState = data && data.state ? data.state : {};
const signature = JSON.stringify([nextNodes, nextState]);
if (signature === lastNodesSnapshotSignature) return false;
lastNodesSnapshotSignature = signature;
nodes = nextNodes;
state = nextState;
stableSortNodes();
updateCountryFilter();
render();
return true;
}
function refreshButtonBusy(message = "正在后台更新...") {
const btn = $("refresh");
if (!btn) return;
btn.disabled = true;
btn.innerHTML = `<svg style="animation: spin 1s linear infinite; width:16px; height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18.5" /></svg>${esc(message)}`;
}
function refreshButtonIdle() {
const btn = $("refresh");
if (!btn) return;
btn.disabled = false;
btn.innerHTML = `<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="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18.5" /></svg>更新节点`;
}
function startRefreshPolling() {
if (refreshPollInterval) clearInterval(refreshPollInterval);
refreshButtonBusy("正在检测节点...");
refreshPollInterval = setInterval(async () => {
if (refreshPollInFlight || !isPageVisible()) return;
refreshPollInFlight = true;
try {
const data = await fetchNodesSnapshot();
applyNodesSnapshot(data);
if (!state.maintenance_running) {
clearInterval(refreshPollInterval);
refreshPollInterval = null;
refreshButtonIdle();
}
} catch (pe) {
clearInterval(refreshPollInterval);
refreshPollInterval = null;
refreshButtonIdle();
} finally {
refreshPollInFlight = false;
}
}, 1000);
}
function startConnectionPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
if (connectionPollInFlight || !isPageVisible()) return;
connectionPollInFlight = true;
try {
const data = await fetchNodesSnapshot();
applyNodesSnapshot(data);
if (!state.is_connecting && !state.maintenance_running) {
clearInterval(pollInterval);
pollInterval = null;
try {
await fetchWithTimeout("./api/test_proxy", { method: "POST" }, 45000);
} catch(pe){}
load();
}
} catch(pe) {
clearInterval(pollInterval);
pollInterval = null;
load();
} finally {
connectionPollInFlight = false;
}
}, 1000);
}
async function connectNode(id){
state.is_connecting = true;
state.pending_node_id = id;
state.active_node_latency = "正在连接";
state.last_check_message = "正在发送连接请求...";
render();
try {
const request = fetchWithTimeout("./api/connect",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id})
}, 180000);
startConnectionPolling();
const r = await request;
const result = await readJsonResponse(r, "连接请求失败");
if (!result.ok) {
if (!result.cancelled) {
alert("连接失败: " + (result.error || "未知错误"));
}
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
state.is_connecting = false;
state.pending_node_id = "";
await load();
return;
}
} catch(e) {
alert("连接请求错误: " + (e.message || "未知错误"));
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
state.is_connecting = false;
state.pending_node_id = "";
try {
await load();
} catch (loadError) {
render();
}
}
}
async function disconnectNode(){
if (disconnectInFlight) return;
if (!confirm("确定要断开当前的 VPN 连接吗?")) return;
disconnectInFlight = true;
render();
try {
const response = await fetchWithTimeout("./api/disconnect", { method: "POST" }, 60000);
const result = await readJsonResponse(response, "断开连接失败");
if (result.ok) {
try {
await fetchWithTimeout("./api/test_proxy", { method: "POST" }, 45000);
} catch(pe){}
load();
} else {
alert("断开连接失败: " + (result.error || "未知错误"));
}
} catch (e) {
alert("请求断开连接失败: " + (e.message || "未知错误"));
} finally {
disconnectInFlight = false;
render();
}
}
async function load(){
const d = await fetchNodesSnapshot();
applyNodesSnapshot(d);
if (state.maintenance_running) {
startRefreshPolling();
} else if (state.is_connecting) {
startConnectionPolling();
}
}
$("country_filter_button").onclick = event => {
event.stopPropagation();
const isOpen = $("country_filter_button").getAttribute("aria-expanded") === "true";
setCountryFilterOpen(!isOpen);
};
$("country_filter_panel").onclick = event => event.stopPropagation();
document.addEventListener("click", () => setCountryFilterOpen(false));
document.addEventListener("keydown", event => {
if (event.key === "Escape") {
const wasOpen = $("country_filter_button").getAttribute("aria-expanded") === "true";
setCountryFilterOpen(false);
if (wasOpen) $("country_filter_button").focus();
}
});
$("ip_type_filter").onchange=()=>{ currentPage = 1; render(); };
$("status_filter").onchange=()=>{ currentPage = 1; render(); };
$("refresh").onclick=async()=>{
refreshButtonBusy("正在启动更新...");
try{
const response = await fetchWithTimeout("./api/refresh_nodes",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
discovery_countries: Array.from(selectedDiscoveryCountries).sort()
})
}, 25000);
const result = await readJsonResponse(response, "节点更新启动失败");
if (!response.ok || !result.ok) {
throw new Error(result.error || "节点更新启动失败");
}
state.discovery_countries = Array.isArray(result.discovery_countries)
? result.discovery_countries
: Array.from(selectedDiscoveryCountries);
discoveryCountriesDirty = false;
await load();
startRefreshPolling();
}
catch(e){
refreshButtonIdle();
alert("更新节点失败: " + (e.message || "未知错误"));
}
};
$("btn_test_proxy").onclick = async () => {
const btn = $("btn_test_proxy");
const badge = $("proxy_status_badge");
const ipVal = $("proxy_ip_val");
const latVal = $("proxy_latency_val");
btn.disabled = true;
btn.innerHTML = `<span class="badge-pulse"></span>测试中...`;
badge.className = "badge not_checked";
badge.textContent = "检测中...";
ipVal.textContent = "-";
latVal.textContent = "";
try {
const response = await fetchWithTimeout("./api/test_proxy", { method: "POST" }, 45000);
const result = await readJsonResponse(response, "代理检测失败");
if (result.ok) {
badge.className = "badge available";
badge.textContent = "可用";
ipVal.textContent = result.ip || "-";
const latencyClass = getLatencyClass(result.latency_ms);
latVal.innerHTML = `<span class="latency-val ${latencyClass}" style="margin-left:8px;">${result.latency_ms} ms</span>`;
} else {
badge.className = "badge unavailable";
badge.textContent = "不可用";
ipVal.textContent = "-";
latVal.innerHTML = `<span class="latency-val latency-poor" style="margin-left:8px; font-size:11px;" title="${esc(result.error)}">连接失败</span>`;
}
} catch (e) {
badge.className = "badge unavailable";
badge.textContent = "网络错误";
ipVal.textContent = "-";
latVal.innerHTML = `<span class="latency-val latency-poor" style="margin-left:8px; font-size:11px;">请求出错</span>`;
} finally {
btn.disabled = false;
btn.innerHTML = `<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 测试代理`;
}
};
// Admin dropdown toggle & GitHub dropdown toggle
const adminBtn = $("admin_btn");
const adminDropdown = $("admin_dropdown");
const githubBtn = $("github_btn");
const githubDropdown = $("github_dropdown");
async function checkForUpdate(event) {
if (event) event.stopPropagation();
const button = $("check_update_btn");
const statusBox = $("update_check_status");
const releaseLink = $("latest_release_link");
if (!button || !statusBox) return;
button.disabled = true;
statusBox.className = "update-check-status";
statusBox.textContent = "正在连接 GitHub 检查最新正式版...";
try {
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 || "更新检查失败");
}
if (releaseLink && result.release_url) releaseLink.href = result.release_url;
if (result.update_available) {
statusBox.className = "update-check-status available";
if (result.deployment_mode === "docker") {
statusBox.textContent = `发现正式版 ${result.latest_tag}。请在 VPS 执行:${result.update_command}`;
} else {
statusBox.textContent = `发现正式版 ${result.latest_tag}。请执行:${result.update_command}`;
}
} else {
statusBox.className = "update-check-status current";
statusBox.textContent = `当前 ${result.current_version_label} 已是最新正式版。`;
}
} catch (error) {
statusBox.className = "update-check-status error";
statusBox.textContent = error.message || "无法连接 GitHub,请稍后重试。";
} finally {
button.disabled = false;
}
}
if (adminBtn && adminDropdown) {
adminBtn.onclick = (e) => {
e.stopPropagation();
const isShow = adminDropdown.style.display === "block";
adminDropdown.style.display = isShow ? "none" : "block";
adminBtn.setAttribute("aria-expanded", isShow ? "false" : "true");
if (githubDropdown) {
githubDropdown.style.display = "none";
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
}
};
}
if (githubBtn && githubDropdown) {
githubBtn.onclick = (e) => {
e.stopPropagation();
const isShow = githubDropdown.style.display === "block";
githubDropdown.style.display = isShow ? "none" : "block";
githubBtn.setAttribute("aria-expanded", isShow ? "false" : "true");
if (adminDropdown) {
adminDropdown.style.display = "none";
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
}
};
githubDropdown.onclick = event => event.stopPropagation();
}
document.addEventListener("click", () => {
if (adminDropdown) adminDropdown.style.display = "none";
if (githubDropdown) githubDropdown.style.display = "none";
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const githubWasOpen = githubBtn && githubBtn.getAttribute("aria-expanded") === "true";
const adminWasOpen = adminBtn && adminBtn.getAttribute("aria-expanded") === "true";
if (adminDropdown) adminDropdown.style.display = "none";
if (githubDropdown) githubDropdown.style.display = "none";
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
if (githubWasOpen && githubBtn) githubBtn.focus();
else if (adminWasOpen && adminBtn) adminBtn.focus();
});
let showFavoritesOnly = false;
function toggleFavoritesView() {
showFavoritesOnly = !showFavoritesOnly;
currentPage = 1;
render();
}
function updateFavPanelUI() {
const panel = $("favorites_panel");
if (!panel) return;
panel.style.display = showFavoritesOnly ? "block" : "none";
const btn = $("btn_favorites");
if (btn) {
if (showFavoritesOnly) {
btn.classList.add("active");
} else {
btn.classList.remove("active");
}
}
if (showFavoritesOnly && state) {
const favRoutingBtn = $("btn_toggle_fav_routing");
if (favRoutingBtn) {
if (state.routing_mode === "favorites") {
favRoutingBtn.textContent = "禁用仅用收藏出站";
favRoutingBtn.style.background = "var(--danger-gradient)";
favRoutingBtn.style.borderColor = "transparent";
favRoutingBtn.style.color = "#ffffff";
favRoutingBtn.style.boxShadow = "0 0 12px rgba(244, 63, 94, 0.3)";
} else {
favRoutingBtn.textContent = "启用仅用收藏出站";
favRoutingBtn.style.background = "rgba(255,255,255,0.03)";
favRoutingBtn.style.borderColor = "var(--border-color)";
favRoutingBtn.style.color = "var(--text-primary)";
favRoutingBtn.style.boxShadow = "none";
}
}
}
}
async function toggleFavRouting() {
if (!state) return;
const newMode = state.routing_mode === "favorites" ? "auto" : "favorites";
state.routing_mode = newMode;
updateFavPanelUI();
try {
const res = await fetchWithTimeout("./api/update_routing", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
routing_mode: newMode,
force_country: state.force_country || "",
routing_ip_type: state.routing_ip_type || "all"
})
}, 25000);
const data = await readJsonResponse(res, "更新出站路由设置失败");
if (res.ok && data.ok) {
load();
} else {
alert("更新出站路由设置失败: " + (data.error || "未知错误"));
load();
}
} catch (err) {
alert("连接服务器失败,请稍后重试");
load();
}
}
function selectOptionCard(groupName, value) {
if (groupName === 'routing_mode') {
const input = $("net_routing_mode");
if (input) input.value = value;
const cards = document.querySelectorAll("#routing_mode_group .option-card");
cards.forEach(card => {
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");
}
});
handleRoutingModeChange(value);
} else if (groupName === 'routing_ip_type') {
const input = $("net_routing_ip_type");
if (input) input.value = value;
const cards = document.querySelectorAll("#routing_ip_type_group .option-card");
cards.forEach(card => {
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");
}
});
}
}
function setRoutingMode(value) {
selectOptionCard('routing_mode', value);
}
function setRoutingIpType(value) {
selectOptionCard('routing_ip_type', value);
}
function handleRoutingModeChange(mode) {
const countryGroup = $("net_force_country_group");
const warningDiv = $("net_routing_warning");
if (mode === "fixed_region") {
countryGroup.style.display = "block";
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>固定地区</strong>:限制仅连接选定国家的节点,且后台仅并发测速该国家的节点。如果该国的所有可用节点都失效,会造成代理中断且<strong>绝不自动切换到其他国家</strong>的节点。`;
} else if (mode === "favorites") {
countryGroup.style.display = "none";
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>仅用收藏</strong>:只连接和切换您收藏的节点。如果所有收藏的节点均失效,系统不会自动切换到未收藏的节点。请确保收藏列表中有足够多且可用的节点。`;
} else if (mode === "fixed_ip") {
countryGroup.style.display = "none";
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>:您可以在主页节点列表中直接点击“连接”按钮来选择并锁定不同的IP节点。`;
} else {
countryGroup.style.display = "none";
warningDiv.style.color = "var(--text-secondary)";
warningDiv.style.background = "rgba(255, 255, 255, 0.02)";
warningDiv.style.border = "1px solid rgba(255, 255, 255, 0.05)";
warningDiv.innerHTML = `️ <strong>自动配置</strong>:全自动测试并选择最佳IP。在使用过程中,如果当前连接节点没有失效,将不再更换IP;如果当前节点失效,系统将立刻秒级自动漂移到其他最快的可用节点。`;
}
}
function populateRoutingCountries() {
const select = $("net_force_country");
if (!select) return;
const countMap = {};
nodes.forEach(n => {
const c = translateCountry(n.country);
if (c) {
countMap[c] = (countMap[c] || 0) + 1;
}
});
const countries = Object.keys(countMap).sort();
let html = '<option value="">请选择要锁定的国家...</option>';
countries.forEach(c => {
html += `<option value="${esc(c)}">${esc(c)} (${countMap[c]}个节点)</option>`;
});
select.innerHTML = html;
if (state) {
select.value = state.force_country ? translateCountry(state.force_country) : "";
}
}
function openCredentialsModal() {
$("credentials_error").style.display = "none";
$("credentials_success").style.display = "none";
$("credentials_form").reset();
if (state) {
$("cred_username").value = state.username || "";
$("cred_password").value = "";
$("cred_port").value = state.port || 8787;
$("cred_suffix").value = state.secret_path || "";
}
showModal("credentials_modal", "#cred_username");
$("admin_dropdown").style.display = "none";
}
function closeCredentialsModal() {
hideModal("credentials_modal");
}
async function saveCredentials(e) {
e.preventDefault();
const errorDivEl = $("credentials_error");
const successDiv = $("credentials_success");
const submitBtn = $("credentials_submit_btn");
errorDivEl.style.display = "none";
successDiv.style.display = "none";
const username = $("cred_username").value.trim();
const password = $("cred_password").value;
const port = parseInt($("cred_port").value);
const suffix = $("cred_suffix").value.trim();
if (!username || (!password && !(state && state.password_set))) {
errorDivEl.textContent = "用户名不能为空;首次设置时密码不能为空";
errorDivEl.style.display = "block";
return;
}
if (isNaN(port) || port < 1 || port > 65535) {
errorDivEl.textContent = "网页管理端口范围必须在 1 至 65535 之间";
errorDivEl.style.display = "block";
return;
}
if (!/^[A-Za-z0-9]+$/.test(suffix)) {
errorDivEl.textContent = "登录安全后缀仅能由英文字母和数字组成";
errorDivEl.style.display = "block";
return;
}
if (state && port === state.proxy_port) {
errorDivEl.textContent = "网页管理端口不能与代理出站端口相同";
errorDivEl.style.display = "block";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "正在保存...";
try {
const res = await fetchWithTimeout("./api/update_credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username,
password: password,
port: port,
secret_path: suffix
})
}, 25000);
const data = await readJsonResponse(res, "保存网页安全设置失败");
if (res.ok && data.ok) {
if (data.restart_needed) {
successDiv.textContent = "保存成功!网页管理端口或路径已变更,页面将在 4 秒内自动跳转...";
successDiv.style.display = "block";
const inputs = $("credentials_form").querySelectorAll("input, button");
inputs.forEach(el => el.disabled = true);
setTimeout(() => {
const protocol = window.location.protocol;
const host = formatUrlHost(window.location.hostname);
window.location.href = `${protocol}//${host}:${port}/${suffix}/`;
}, 4000);
} else {
successDiv.textContent = data.reauth_required ? "账号密码保存成功,请重新登录..." : "账号密码保存成功,已即时生效!";
successDiv.style.display = "block";
setTimeout(() => {
if (data.reauth_required) {
window.location.reload();
} else {
closeCredentialsModal();
load();
}
}, 1500);
}
} else {
errorDivEl.textContent = data.error || "保存失败,请检查输入";
errorDivEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = "保存修改";
}
} catch (err) {
errorDivEl.textContent = "连接服务器失败,请稍后重试";
errorDivEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = "保存修改";
}
}
function openNetworkModal() {
$("network_error").style.display = "none";
$("network_success").style.display = "none";
$("network_form").reset();
if (state) {
$("net_proxy_port").value = state.proxy_port || 7928;
const mode = state.routing_mode || "auto";
const ipType = state.routing_ip_type || "all";
selectOptionCard('routing_mode', mode);
selectOptionCard('routing_ip_type', ipType);
}
populateRoutingCountries();
showModal("network_modal", "#net_proxy_port");
$("admin_dropdown").style.display = "none";
}
function closeNetworkModal() {
hideModal("network_modal");
}
async function saveNetwork(e) {
e.preventDefault();
const errorDivEl = $("network_error");
const successDiv = $("network_success");
const submitBtn = $("network_submit_btn");
errorDivEl.style.display = "none";
successDiv.style.display = "none";
const proxyPort = parseInt($("net_proxy_port").value);
const routingMode = $("net_routing_mode").value;
const forceCountry = $("net_force_country").value;
const routingIpType = $("net_routing_ip_type").value;
if (isNaN(proxyPort) || proxyPort < 1024 || proxyPort > 65535) {
errorDivEl.textContent = "代理出站端口范围必须在 1024 至 65535 之间";
errorDivEl.style.display = "block";
return;
}
if (state && proxyPort === state.port) {
errorDivEl.textContent = "代理出站端口不能与网页管理端口相同";
errorDivEl.style.display = "block";
return;
}
if (routingMode === "fixed_region" && !forceCountry) {
errorDivEl.textContent = "请选择一个要锁定的目标国家";
errorDivEl.style.display = "block";
return;
}
if (routingMode === "fixed_ip" && !(state && (state.active_openvpn_node_id || state.fixed_node_id))) {
errorDivEl.textContent = "启用固定 IP 前,请先连接一个要锁定的节点";
errorDivEl.style.display = "block";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "正在保存...";
try {
const res = await fetchWithTimeout("./api/update_settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proxy_port: proxyPort,
routing_mode: routingMode,
force_country: forceCountry,
routing_ip_type: routingIpType
})
}, 25000);
const data = await readJsonResponse(res, "保存代理设置失败");
if (res.ok && data.ok) {
if (data.restart_needed) {
successDiv.textContent = "保存成功!代理出站端口已变更,页面将在 4 秒内自动刷新...";
successDiv.style.display = "block";
const inputs = $("network_form").querySelectorAll("input, button");
inputs.forEach(el => el.disabled = true);
setTimeout(() => {
window.location.reload();
}, 4000);
} else {
successDiv.textContent = "配置保存成功,已即时生效!";
successDiv.style.display = "block";
setTimeout(() => {
closeNetworkModal();
load();
}, 1500);
}
} else {
errorDivEl.textContent = data.error || "保存失败,请检查输入";
errorDivEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = "保存修改";
}
} catch (err) {
errorDivEl.textContent = "连接服务器失败,请稍后重试";
errorDivEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = "保存修改";
}
}
function openVpsModal() {
showModal("vps_recommend_modal");
}
function closeVpsModal() {
hideModal("vps_recommend_modal");
}
async function logoutAdmin() {
try {
const res = await fetchWithTimeout("./api/logout", { method: "POST" }, 20000);
if (res.ok) {
window.location.reload();
} else {
alert("退出登录失败,请稍后重试");
}
} catch (err) {
console.error("退出登录失败", err);
alert("退出登录失败: " + (err.message || "网络错误"));
}
}
// 页面加载时自动初始化数据
load().catch(error => console.error("初始化节点数据失败", error));
// 每 10 秒在前台空闲时自动更新节点与状态,无需手动刷新页面
let backgroundPollInFlight = false;
setInterval(async () => {
if (backgroundPollInFlight || !isPageVisible()) return;
if (typeof state !== "undefined" && !state.is_connecting && !state.maintenance_running && (!testingNodeIds || !testingNodeIds.size)) {
backgroundPollInFlight = true;
try {
const data = await fetchNodesSnapshot();
applyNodesSnapshot(data);
} catch(e) {
} finally {
backgroundPollInFlight = false;
}
}
}, 10000);
let gatewayPollInterval = null;
let gatewayRequestInFlight = false;
function openGatewayModal() {
$("admin_dropdown").style.display = "none";
showModal("gateway_modal", "#btn_test_proxy");
loadGatewayStatus();
if (gatewayPollInterval) clearInterval(gatewayPollInterval);
gatewayPollInterval = setInterval(loadGatewayStatus, 3000);
}
function closeGatewayModal() {
hideModal("gateway_modal");
if (gatewayPollInterval) {
clearInterval(gatewayPollInterval);
gatewayPollInterval = null;
}
}
async function loadGatewayStatus() {
if (gatewayRequestInFlight || !isPageVisible() || $("gateway_modal").style.display !== "flex") return;
gatewayRequestInFlight = true;
try {
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) {
renderGatewayServices(data.services);
}
} catch (e) {
console.error("加载网关状态失败", e);
} finally {
gatewayRequestInFlight = false;
}
}
function renderGatewayServices(services) {
const container = $("gateway_services_list");
if (!container) return;
let html = "";
services.forEach(s => {
const statusText = s.status === "running" ? "正在运行" : "已停止";
const badgeClass = s.status === "running" ? "available" : "unavailable";
const statusPulse = s.status === "running" ? '<span class="badge-pulse"></span>' : '';
html += `
<div style="background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border-color); border-radius: 10px; padding: 12px 16px; display: flex; flex-direction: column; gap: 6px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<strong style="font-size: 14px; color: var(--text-primary);">${esc(s.name)}</strong>
<span class="badge ${badgeClass}">${statusPulse}${statusText}</span>
</div>
<div style="font-size: 12px; color: var(--text-secondary);">${esc(s.details || "-")}</div>
${s.error ? `
<div style="font-size: 12px; color: var(--danger); background: rgba(244,63,94,0.08); border: 1px solid rgba(244,63,94,0.15); border-radius: 6px; padding: 6px 10px; margin-top: 4px; line-height: 1.4;">
⚠️ 诊断原因: ${esc(s.error)}
</div>
` : ''}
</div>
`;
});
setHtmlIfChanged(container, html);
}
let logsPollInterval = null;
let rawLogsCache = [];
let logsRequestInFlight = false;
const MAX_RENDERED_LOG_LINES = 300;
function openLogsModal() {
$("admin_dropdown").style.display = "none";
showModal("logs_modal", "#log_filter_select");
loadLogs();
if (logsPollInterval) clearInterval(logsPollInterval);
logsPollInterval = setInterval(loadLogs, 2500);
}
function closeLogsModal() {
hideModal("logs_modal");
if (logsPollInterval) {
clearInterval(logsPollInterval);
logsPollInterval = null;
}
}
async function loadLogs() {
if (logsRequestInFlight || !isPageVisible() || $("logs_modal").style.display !== "flex") return;
logsRequestInFlight = true;
try {
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)) {
rawLogsCache = data.logs;
filterAndRenderLogs();
}
} catch (e) {
console.error("加载日志失败", e);
} finally {
logsRequestInFlight = false;
}
}
function filterAndRenderLogs() {
const filterVal = $("log_filter_select").value;
const term = $("log_terminal_container");
if (!term) return;
let filtered = rawLogsCache;
if (filterVal === "proxy") {
filtered = rawLogsCache.filter(l => l.module === "Proxy");
} else if (filterVal === "vpn") {
filtered = rawLogsCache.filter(l => l.module === "VPN");
} else if (filterVal === "system") {
filtered = rawLogsCache.filter(l => !["Proxy", "VPN"].includes(l.module));
}
if (filtered.length === 0) {
setHtmlIfChanged(term, `<div style="color: var(--text-secondary); text-align: center; margin-top: 150px;">暂无该类型日志。</div>`);
return;
}
const linesHtml = filtered.slice(-MAX_RENDERED_LOG_LINES).map(l => {
let color = "#a5b4fc";
if (l.module === "Proxy") color = "#38bdf8";
if (l.module === "VPN") color = "#34d399";
if (l.level === "WARNING") color = "#fbbf24";
if (l.level === "ERROR") color = "#f43f5e";
return `<div style="color: ${color}; margin-bottom: 4px;">[${esc(l.timestamp)}] [${esc(l.level)}] [${esc(l.module)}] ${esc(l.message)}</div>`;
}).join("");
const isAtBottom = term.scrollHeight - term.clientHeight <= term.scrollTop + 50;
const changed = setHtmlIfChanged(term, linesHtml);
if (changed && isAtBottom) {
term.scrollTop = term.scrollHeight;
}
}
function copyLogContent() {
const term = $("log_terminal_container");
if (!term) return;
const text = term.innerText || term.textContent;
if (!text || text.includes("暂无今日") || text.includes("暂无该类型")) {
alert("当前没有可供复制的日志。");
return;
}
navigator.clipboard.writeText(text).then(() => {
alert("日志内容已成功复制到剪贴板!");
}).catch(err => {
console.error("复制失败", err);
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
alert("日志内容已复制到剪贴板!");
});
}
function exportLogContent() {
const term = $("log_terminal_container");
if (!term) return;
const text = term.innerText || term.textContent;
if (!text || text.includes("暂无今日") || text.includes("暂无该类型")) {
alert("当前没有可供导出的日志。");
return;
}
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const dateStr = new Date().toISOString().slice(0, 10);
const filterVal = $("log_filter_select").value;
a.download = `vpngate_log_${filterVal}_${dateStr}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
</body></html>"""
def check_proxy_health() -> dict[str, Any]:
# 1. 检测代理服务端口是否在监听
is_ipv6 = ":" in LOCAL_PROXY_HOST
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = None
try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(1.5)
connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""):
connect_host = "::1" if is_ipv6 else "127.0.0.1"
try:
s.connect((connect_host, LOCAL_PROXY_PORT))
except Exception as e:
if connect_host == "::1":
s.close()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1.5)
s.connect(("127.0.0.1", LOCAL_PROXY_PORT))
else:
raise e
except Exception as e:
diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT, host=LOCAL_PROXY_HOST)
diag_msg = diag[1] if diag else f"端口 {LOCAL_PROXY_PORT} 连接失败,原因: {e}"
return {
"ok": False,
"error": f"代理服务未运行 ({diag_msg})"
}
finally:
if s is not None:
try:
s.close()
except Exception:
pass
# 2. 检测虚拟网卡 tun0 是否存在 (Linux 下)
tun_path = Path("/sys/class/net/tun0")
if sys.platform.startswith("linux") and not tun_path.exists():
return {
"ok": False,
"error": "[错误代码 3004] [ERR_ROUTE_DEV_NOT_FOUND] VPN 虚拟网卡 (tun0) 未启用,请确保当前已成功连接 VPN 节点"
}
# 3. 使用 curl 通过本地 SOCKS5 代理接口测试 IP 与实际延迟
def _curl_check_ip(url: str) -> dict[str, Any] | None:
proxy_hosts = []
if LOCAL_PROXY_HOST == "::":
proxy_hosts = ["[::1]", "127.0.0.1"]
elif LOCAL_PROXY_HOST == "0.0.0.0":
proxy_hosts = ["127.0.0.1"]
elif ":" in LOCAL_PROXY_HOST:
proxy_hosts = [f"[{LOCAL_PROXY_HOST}]", "127.0.0.1"]
else:
proxy_hosts = [LOCAL_PROXY_HOST]
for p_host in proxy_hosts:
proxy_url = f"socks5h://{p_host}:{LOCAL_PROXY_PORT}"
proxy_user, proxy_pass = proxy_server.get_proxy_credentials()
cmd = [
"curl", "-s",
"-w", "\n%{time_total} %{http_code}",
"-x", proxy_url,
url,
"--max-time", "5"
]
if proxy_user is not None and proxy_pass is not None:
cmd.extend(["--proxy-user", f"{proxy_user}:{proxy_pass}"])
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=6)
if res.returncode == 0:
lines = res.stdout.strip().splitlines()
if len(lines) >= 2:
ip = lines[0].strip()
time_info = lines[1].strip().split()
if len(time_info) == 2:
total_time_str, http_code = time_info
if http_code == "200" and ip:
latency_ms = int(float(total_time_str) * 1000)
return {"ok": True, "ip": ip, "latency_ms": latency_ms}
except Exception:
pass
return None
try:
result = _curl_check_ip("http://ip.sb")
if result:
return result
result = _curl_check_ip("http://api.ipify.org")
if result:
return result
# 此时外网测试失败,检测本地代理端口是否依然能连通。若仍能连通,直接抛出出口测试失败,不调用占用诊断
port_still_listening = False
test_sock = None
try:
test_sock = socket.socket(af, socket.SOCK_STREAM)
test_sock.settimeout(1.0)
connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""):
connect_host = "::1" if is_ipv6 else "127.0.0.1"
try:
test_sock.connect((connect_host, LOCAL_PROXY_PORT))
port_still_listening = True
except Exception:
if connect_host == "::1":
test_sock.close()
test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
test_sock.settimeout(1.0)
test_sock.connect(("127.0.0.1", LOCAL_PROXY_PORT))
port_still_listening = True
except Exception:
pass
finally:
if test_sock is not None:
try:
test_sock.close()
except Exception:
pass
if not port_still_listening:
diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT, host=LOCAL_PROXY_HOST)
if diag:
return {"ok": False, "error": f"出口连接测试失败 | 本机诊断结果: {diag[1]}"}
return {"ok": False, "error": "出口连接测试失败 (ip.sb 和 api.ipify.org 均无法连通,可能是节点已失效或 VPS 防火墙限制了 UDP/TCP 出站端口)"}
except Exception as e:
return {"ok": False, "error": f"出口连接测试异常: {e}"}
def reset_proxy_failure_counter(node_id: str = "") -> None:
global consecutive_proxy_failures, last_proxy_failure_node_id
with lock:
consecutive_proxy_failures = 0
last_proxy_failure_node_id = node_id
def record_proxy_failure(node_id: str) -> int:
global consecutive_proxy_failures, last_proxy_failure_node_id
with lock:
if node_id != last_proxy_failure_node_id:
consecutive_proxy_failures = 0
last_proxy_failure_node_id = node_id
consecutive_proxy_failures += 1
return consecutive_proxy_failures
def background_proxy_checker() -> None:
global last_checker_heartbeat, is_connecting
time.sleep(30)
while True:
last_checker_heartbeat = time.time()
try:
if is_connecting:
time.sleep(5)
continue
checked_node_id = active_openvpn_node_id
res = check_proxy_health()
if checked_node_id != active_openvpn_node_id:
continue
if res["ok"]:
reset_proxy_failure_counter(checked_node_id)
set_state(
proxy_ok=True,
proxy_ip=res["ip"],
proxy_latency_ms=res["latency_ms"],
proxy_error=""
)
log_to_json("INFO", "Proxy", f"代理可用,IP: {res['ip']}, 延迟: {res['latency_ms']} ms")
else:
error_msg = res.get("error", "未知错误")
failure_count = record_proxy_failure(checked_node_id) if checked_node_id else 0
process_exited = bool(checked_node_id) and not active_openvpn_running()
should_recover = process_exited or failure_count >= PROXY_FAILURE_THRESHOLD
if checked_node_id:
print(f"[警告] {LOCAL_PROXY_PORT} 端口本地代理当前不可用!原因: {error_msg}", flush=True)
log_to_json(
"WARNING",
"Proxy",
f"代理不可用 ({failure_count}/{PROXY_FAILURE_THRESHOLD}): {error_msg}",
)
display_error = error_msg
if checked_node_id and not process_exited and not should_recover:
display_error = f"{error_msg}(连续失败 {failure_count}/{PROXY_FAILURE_THRESHOLD},暂不切换)"
set_state(
proxy_ok=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error=display_error,
)
# A dead OpenVPN process is recovered immediately. Transient
# external probe failures must cross the configured threshold.
if checked_node_id and should_recover:
reset_proxy_failure_counter(checked_node_id)
ui_cfg = load_ui_config()
routing_mode = ui_cfg.get("routing_mode", "auto")
if routing_mode != "fixed_ip":
with lock:
nodes = read_nodes()
active_node = next((n for n in nodes if n.get("id") == checked_node_id), None)
if active_node:
mark_blacklisted(active_node, f"代理连通性检测失败: {error_msg}")
active_node["probe_status"] = "unavailable"
write_json(NODES_FILE, nodes)
auto_switch_node()
else:
print(f"[代理守护线程] 固定 IP 模式下代理不可用,正在尝试重启连接同一节点: {checked_node_id}", flush=True)
try:
connect_node(checked_node_id)
except Exception as e:
print(f"[代理守护线程] 重启固定节点失败: {e}", flush=True)
except Exception as e:
print(f"[错误] 代理后台检测发生异常: {e}", flush=True)
log_to_json("ERROR", "Proxy", f"检测守护线程发生异常: {e}")
time.sleep(30)
def active_node_pinger() -> None:
global last_pinger_heartbeat
while True:
last_pinger_heartbeat = time.time()
try:
if active_openvpn_running() and active_openvpn_node_id:
nodes = read_nodes()
node = next((n for n in nodes if n.get("id") == active_openvpn_node_id), None)
if node:
ip = node.get("ip") or node.get("remote_host")
port = parse_int(node.get("remote_port"))
fallback = parse_int(node.get("ping"))
if ip:
latency = vpn_utils.ping_latency_ms(ip, port, fallback)
if latency > 0:
set_state(active_node_latency=f"{latency} ms")
else:
set_state(active_node_latency="检测超时")
else:
set_state(active_node_latency="检测超时")
else:
set_state(active_node_latency="检测超时")
elif is_connecting:
set_state(active_node_latency="测试中...")
else:
set_state(active_node_latency="无活动连接")
except Exception as e:
print(f"[ERROR] active_node_pinger error: {e}", flush=True)
time.sleep(10)
class Handler(BaseHTTPRequestHandler):
def get_secret_path(self) -> str:
ui_cfg = load_ui_config()
return ui_cfg.get("secret_path", "EJsW2EeBo9lY")
def is_authorized(self) -> bool:
ui_cfg = load_ui_config()
pwd = ui_cfg.get("password")
if not pwd:
print("[Auth] 管理后台密码为空,已拒绝访问。请检查 ui_auth.json。", flush=True)
return False
cookie_header = self.headers.get("Cookie", "")
cookies = {}
if cookie_header:
for item in cookie_header.split(";"):
item = item.strip()
if "=" in item:
k, v = item.split("=", 1)
cookies[k.strip()] = v.strip()
session_token = cookies.get("session")
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():
return True
return False
def validate_path(self) -> str:
secret_path = self.get_secret_path()
request_path = urllib.parse.urlsplit(self.path).path
if not secret_path:
return request_path
if request_path == f"/{secret_path}":
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", f"/{secret_path}/")
self.end_headers()
return ""
prefix = f"/{secret_path}/"
if request_path.startswith(prefix):
return "/" + request_path[len(prefix):]
self.send_response(HTTPStatus.NOT_FOUND)
self.end_headers()
return ""
def log_message(self, format: str, *args: Any) -> None:
print(f"[{self.log_date_time_string()}] {format % args}", flush=True)
def send_bytes(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
self.send_bytes(json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8", status)
def read_request_body(self, max_bytes: int = 65536) -> bytes:
length = parse_int(self.headers.get("Content-Length"))
if length < 0:
raise ValueError("Content-Length 无效")
if length > max_bytes:
raise ValueError(f"请求体过大,最大允许 {max_bytes} 字节")
return self.rfile.read(length) if length > 0 else b""
def read_json_body(self, max_bytes: int = 65536) -> dict[str, Any]:
body = self.read_request_body(max_bytes)
if not body:
return {}
data = json.loads(body.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("请求 JSON 必须是对象")
return data
def do_GET(self) -> None:
effective_path = self.validate_path()
if effective_path == "": return
if not self.is_authorized():
if effective_path in ("/", "/index.html"):
self.send_bytes(LOGIN_HTML.encode("utf-8"), "text/html; charset=utf-8")
return
else:
self.send_json({"error": "Unauthorized"}, HTTPStatus.UNAUTHORIZED)
return
if effective_path in ("/", "/index.html"):
self.send_bytes(INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8")
elif effective_path == "/api/nodes":
global last_active_ping_time, last_active_latency, active_openvpn_node_id
nodes = read_nodes()
active_node = next((n for n in nodes if active_openvpn_node_id and n.get("id") == active_openvpn_node_id), None)
for n in nodes:
n["active"] = (active_openvpn_node_id and n.get("id") == active_openvpn_node_id)
if active_node:
ip = active_node.get("ip") or active_node.get("remote_host")
if ip:
now = time.time()
if now - last_active_ping_time > 15.0:
last_active_ping_time = now
def bg_ping(ip_addr: str, port: int, fallback: int) -> None:
global last_active_latency
try:
latency = vpn_utils.ping_latency_ms(ip_addr, port, fallback)
if latency > 0:
last_active_latency = latency
except Exception:
pass
threading.Thread(
target=bg_ping,
args=(ip, parse_int(active_node.get("remote_port")), parse_int(active_node.get("ping"))),
daemon=True
).start()
if last_active_latency > 0:
active_node["latency_ms"] = last_active_latency
stripped_nodes = []
for n in nodes:
stripped = n.copy()
if "config_text" in stripped:
del stripped["config_text"]
stripped_nodes.append(stripped)
self.send_json({"nodes": stripped_nodes, "state": get_state()})
elif effective_path == "/api/check_update":
try:
self.send_json(check_latest_release())
except Exception as exc:
self.send_json(
{"ok": False, "error": f"无法检查 GitHub 正式版更新: {exc}"},
HTTPStatus.BAD_GATEWAY,
)
elif effective_path.startswith("/configs/"):
filename = urllib.parse.unquote(effective_path.removeprefix("/configs/"))
with lock:
nodes = read_nodes()
node = next((n for n in nodes if Path(n.get("config_file", "")).name == filename), None)
if node and node.get("config_text"):
self.send_bytes(node["config_text"].encode("utf-8"), "application/x-openvpn-profile")
else:
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
elif effective_path == "/api/gateway_status":
web_ui_status = {
"name": "Web 管理服务",
"status": "running",
"details": f"监听地址: {load_ui_config().get('host', UI_HOST)}:{load_ui_config().get('port', UI_PORT)}",
"error": ""
}
proxy_ok = False
proxy_err = ""
is_ipv6 = ":" in LOCAL_PROXY_HOST
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = None
try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(0.5)
connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""):
connect_host = "::1" if is_ipv6 else "127.0.0.1"
try:
s.connect((connect_host, LOCAL_PROXY_PORT))
proxy_ok = True
except Exception:
if connect_host == "::1":
s.close()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.5)
s.connect(("127.0.0.1", LOCAL_PROXY_PORT))
proxy_ok = True
else:
raise
except Exception as e:
diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT, host=LOCAL_PROXY_HOST)
proxy_err = diag[1] if diag else f"本地代理网关无法连通: {e}"
finally:
if s is not None:
try:
s.close()
except Exception:
pass
proxy_gateway_status = {
"name": "本地代理网关",
"status": "running" if proxy_ok else "stopped",
"details": f"监听地址: {LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}",
"error": proxy_err
}
ovpn_ok = active_openvpn_running()
ovpn_err = ""
ovpn_details = "未连接"
if ovpn_ok:
ovpn_details = f"已连接节点: {active_openvpn_node_id}"
if sys.platform.startswith("linux"):
if not Path("/sys/class/net/tun0").exists():
ovpn_err = "[警告] 虚拟网卡 (tun0) 未启用,可能存在策略路由配置问题。"
else:
if active_openvpn_node_id:
ovpn_err = "连接已中断或 OpenVPN 核心程序异常退出。"
ovpn_details = f"尝试连接节点 {active_openvpn_node_id} 失败"
openvpn_status = {
"name": "OpenVPN 核心连接",
"status": "running" if ovpn_ok else "stopped",
"details": ovpn_details,
"error": ovpn_err
}
now = time.time()
server_uptime = now - server_start_time
collector_ok = (last_collector_heartbeat > 0.0 and now - last_collector_heartbeat < (CHECK_INTERVAL_SECONDS * 1.5)) or (server_uptime < 15.0)
collector_status = {
"name": "节点同步守护线程",
"status": "running" if collector_ok else "stopped",
"details": f"上次心跳: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last_collector_heartbeat)) if last_collector_heartbeat > 0 else '等待启动'}",
"error": "" if collector_ok else "线程可能已异常终止,导致无法在后台拉取和测速新节点。"
}
checker_ok = (last_checker_heartbeat > 0.0 and now - last_checker_heartbeat < 90.0) or (server_uptime < 35.0)
checker_status = {
"name": "出口检测守护线程",
"status": "running" if checker_ok else "stopped",
"details": f"上次心跳: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last_checker_heartbeat)) if last_checker_heartbeat > 0 else '等待启动'}",
"error": "" if checker_ok else "线程可能已挂起或终止,导致无法实时获取代理出口状态。"
}
pinger_ok = (last_pinger_heartbeat > 0.0 and now - last_pinger_heartbeat < 30.0) or (server_uptime < 15.0)
pinger_status = {
"name": "延迟测速守护线程",
"status": "running" if pinger_ok else "stopped",
"details": f"上次心跳: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last_pinger_heartbeat)) if last_pinger_heartbeat > 0 else '等待启动'}",
"error": "" if pinger_ok else "线程可能已中止,无法实时刷新活动节点的 Ping 延迟。"
}
self.send_json({
"ok": True,
"services": [
web_ui_status,
proxy_gateway_status,
openvpn_status,
collector_status,
checker_status,
pinger_status
]
})
elif effective_path == "/api/logs":
logs_dir = DATA_DIR / "logs"
date_str = time.strftime("%Y-%m-%d", time.localtime())
log_file = logs_dir / f"{date_str}.json"
entries: list[dict[str, Any]] = []
if log_file.exists():
try:
with lock:
entries = read_recent_log_entries(log_file)
except Exception as e:
print(f"[API Logs] Error reading log file: {e}", flush=True)
self.send_json({"logs": entries})
else:
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
global is_connecting
effective_path = self.validate_path()
if effective_path == "": return
if effective_path == "/api/login":
try:
payload = self.read_json_body()
input_pwd = str(payload.get("password") or "")
input_uname = str(payload.get("username") or "")
ui_cfg = load_ui_config()
expected_pwd = ui_cfg.get("password", "")
expected_uname = ui_cfg.get("username", "admin")
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")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
secret_path = self.get_secret_path()
cookie_path = f"/{secret_path}/" if secret_path else "/"
self.send_header("Set-Cookie", f"session={token}; Path={cookie_path}; HttpOnly; SameSite=Lax; Max-Age=2592000")
self.end_headers()
self.wfile.write(body)
else:
self.send_json({"ok": False, "error": "用户名或密码不正确,请重新输入"}, HTTPStatus.FORBIDDEN)
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
if effective_path == "/api/logout":
try:
cookie_header = self.headers.get("Cookie", "")
cookies = {}
if cookie_header:
for item in cookie_header.split(";"):
item = item.strip()
if "=" in item:
k, v = item.split("=", 1)
cookies[k.strip()] = v.strip()
session_token = cookies.get("session")
if session_token:
with lock:
active_sessions.pop(session_token, None)
secret_path = self.get_secret_path()
cookie_path = f"/{secret_path}/" if secret_path else "/"
body = json.dumps({"ok": True}).encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("Set-Cookie", f"session=; Path={cookie_path}; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT")
self.end_headers()
self.wfile.write(body)
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
if not self.is_authorized():
self.send_json({"error": "Unauthorized"}, HTTPStatus.UNAUTHORIZED)
return
if effective_path == "/api/update_credentials":
try:
payload = self.read_json_body()
new_username = str(payload.get("username") or "").strip()
new_password = str(payload.get("password") or "")
new_port = payload.get("port")
new_suffix = str(payload.get("secret_path") or "").strip()
ui_cfg = load_ui_config()
if not new_username or (not new_password and not ui_cfg.get("password")):
self.send_json({"ok": False, "error": "用户名不能为空;首次设置时密码不能为空"}, HTTPStatus.BAD_REQUEST)
return
try:
new_port_int = int(new_port)
if not (1 <= new_port_int <= 65535):
raise ValueError()
except (TypeError, ValueError):
self.send_json({"ok": False, "error": "网页管理端口范围必须是 1 至 65535"}, HTTPStatus.BAD_REQUEST)
return
if not new_suffix or not re.match(r"^[A-Za-z0-9]+$", new_suffix):
self.send_json({"ok": False, "error": "安全后缀仅能由英文字母和数字组成"}, HTTPStatus.BAD_REQUEST)
return
expected_username = ui_cfg.get("username", "")
expected_password = ui_cfg.get("password", "")
expected_port = ui_cfg.get("port", 8787)
expected_suffix = ui_cfg.get("secret_path", "EJsW2EeBo9lY")
ui_cfg["username"] = new_username
if new_password:
ui_cfg["password"] = new_password
ui_cfg["port"] = new_port_int
ui_cfg["secret_path"] = new_suffix
auth_file = DATA_DIR / "ui_auth.json"
reauth_required = new_username != expected_username or (new_password and new_password != expected_password)
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
if reauth_required:
active_sessions.clear()
restart_needed = (new_port_int != expected_port or new_suffix != expected_suffix)
if restart_needed:
self.send_json({"ok": True, "restart_needed": True, "reauth_required": reauth_required, "message": "配置更新成功,网页管理端口或路径已变更,将在 2 秒内重启..."})
def restart_server():
time.sleep(2)
print("[系统] 管理后台安全配置更新,进程即将退出以触发自动重启...", flush=True)
os._exit(0)
threading.Thread(target=restart_server, daemon=True).start()
else:
self.send_json({"ok": True, "restart_needed": False, "reauth_required": reauth_required, "message": "账号密码配置更新成功,已即时生效!"})
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
elif effective_path == "/api/update_settings":
try:
payload = self.read_json_body()
new_proxy_port = payload.get("proxy_port")
routing_mode = str(payload.get("routing_mode") or "auto").strip()
force_country = str(payload.get("force_country") or "").strip()
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
try:
new_proxy_port_int = int(new_proxy_port)
if not (1024 <= new_proxy_port_int <= 65535):
raise ValueError()
except (TypeError, ValueError):
self.send_json({"ok": False, "error": "代理出站端口范围必须是 1024 至 65535"}, HTTPStatus.BAD_REQUEST)
return
if routing_mode not in ("auto", "fixed_ip", "fixed_region", "favorites"):
self.send_json({"ok": False, "error": "无效的路由配置模式"}, HTTPStatus.BAD_REQUEST)
return
if routing_mode == "fixed_region" and not force_country:
self.send_json({"ok": False, "error": "启用固定地区前,请先选择一个要锁定的国家"}, HTTPStatus.BAD_REQUEST)
return
if routing_ip_type not in ("all", "residential", "hosting"):
self.send_json({"ok": False, "error": "无效的IP出站类型过滤"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg = load_ui_config()
expected_proxy_port = ui_cfg.get("proxy_port", 7928)
fixed_node_id = current_fixed_node_id(ui_cfg) if routing_mode == "fixed_ip" else ""
if new_proxy_port_int == ui_cfg.get("port", 8787):
self.send_json({"ok": False, "error": "代理出站端口不能与网页管理端口相同"}, HTTPStatus.BAD_REQUEST)
return
if routing_mode == "fixed_ip" and not fixed_node_id:
self.send_json({"ok": False, "error": "启用固定 IP 前,请先连接一个要锁定的节点"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg["proxy_port"] = new_proxy_port_int
ui_cfg["routing_mode"] = routing_mode
ui_cfg["force_country"] = force_country
ui_cfg["routing_ip_type"] = routing_ip_type
if routing_mode == "favorites":
ui_cfg["fav_fail_fallback"] = False
if routing_mode == "fixed_ip":
ui_cfg["fixed_node_id"] = fixed_node_id
auth_file = DATA_DIR / "ui_auth.json"
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
policy_message = enforce_active_node_allowed_by_routing(ui_cfg, "路由设置已更新")
restart_needed = (new_proxy_port_int != expected_proxy_port)
if restart_needed:
self.send_json({"ok": True, "restart_needed": True, "message": "配置更新成功,代理出站端口变更,将在 2 秒内重启..."})
def restart_server():
time.sleep(2)
print("[系统] 代理出站端口变更,进程即将退出以触发自动重启...", flush=True)
os._exit(0)
threading.Thread(target=restart_server, daemon=True).start()
else:
message = policy_message or "配置更新成功,已即时生效!"
self.send_json({"ok": True, "restart_needed": False, "message": message})
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
elif effective_path == "/api/update_routing":
try:
payload = self.read_json_body()
routing_mode = str(payload.get("routing_mode") or "auto").strip()
force_country = str(payload.get("force_country") or "").strip()
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
fav_fail_fallback = False
if routing_mode not in ("auto", "fixed_ip", "fixed_region", "favorites"):
self.send_json({"ok": False, "error": "无效的路由配置模式"}, HTTPStatus.BAD_REQUEST)
return
if routing_mode == "fixed_region" and not force_country:
self.send_json({"ok": False, "error": "启用固定地区前,请先选择一个要锁定的国家"}, HTTPStatus.BAD_REQUEST)
return
if routing_ip_type not in ("all", "residential", "hosting"):
self.send_json({"ok": False, "error": "无效的IP出站类型过滤"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg = load_ui_config()
fixed_node_id = current_fixed_node_id(ui_cfg) if routing_mode == "fixed_ip" else ""
if routing_mode == "fixed_ip" and not fixed_node_id:
self.send_json({"ok": False, "error": "启用固定 IP 前,请先连接一个要锁定的节点"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg["routing_mode"] = routing_mode
ui_cfg["force_country"] = force_country
ui_cfg["routing_ip_type"] = routing_ip_type
ui_cfg["fav_fail_fallback"] = fav_fail_fallback
if routing_mode == "fixed_ip":
ui_cfg["fixed_node_id"] = fixed_node_id
ui_cfg.pop("enable_force_country", None)
auth_file = DATA_DIR / "ui_auth.json"
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
policy_message = enforce_active_node_allowed_by_routing(ui_cfg, "出站路由配置已更新")
self.send_json({"ok": True, "message": policy_message or "出站路由配置更新成功,已即时生效!"})
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
elif effective_path == "/api/toggle_favorite":
try:
payload = self.read_json_body()
node_id = str(payload.get("id") or "").strip()
if not node_id:
self.send_json({"ok": False, "error": "节点 ID 不能为空"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg = load_ui_config()
fav_ids = ui_cfg.get("favorite_node_ids", [])
if not isinstance(fav_ids, list):
fav_ids = []
if node_id in fav_ids:
fav_ids.remove(node_id)
else:
fav_ids.append(node_id)
ui_cfg["favorite_node_ids"] = fav_ids
auth_file = DATA_DIR / "ui_auth.json"
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
policy_message = None
if ui_cfg.get("routing_mode") == "favorites":
policy_message = enforce_active_node_allowed_by_routing(ui_cfg, "收藏列表已更新")
self.send_json({"ok": True, "favorite_node_ids": fav_ids, "message": policy_message or ""})
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
if effective_path == "/api/check":
try:
self.send_json({"ok": True, "message": maintain_valid_nodes(force=True)})
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
elif effective_path == "/api/refresh_nodes":
try:
payload = self.read_json_body()
if "discovery_countries" in payload:
discovery_countries = persist_discovery_countries(
payload.get("discovery_countries")
)
else:
discovery_countries = normalize_discovery_countries(
load_ui_config().get("discovery_countries")
)
if maintenance_lock.locked():
self.send_json({
"ok": True,
"message": "节点维护任务正在运行,国家范围已保存并将在下一轮生效",
"running": True,
"discovery_countries": discovery_countries,
})
else:
threading.Thread(target=maintain_valid_nodes, args=(False,), daemon=True).start()
self.send_json({
"ok": True,
"message": "已在后台启动节点更新流程",
"running": True,
"discovery_countries": discovery_countries,
})
except ValueError as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
elif effective_path == "/api/test_nodes":
try:
payload = self.read_json_body(max_bytes=262144)
node_ids = payload.get("ids", [])
if not isinstance(node_ids, list):
self.send_json({"ok": False, "error": "节点 ID 列表无效"}, HTTPStatus.BAD_REQUEST)
return
node_ids = [str(node_id or "").strip() for node_id in node_ids]
node_ids = [node_id for node_id in node_ids if node_id]
if len(node_ids) > MANUAL_TEST_NODE_LIMIT:
self.send_json({"ok": False, "error": f"单次最多测试 {MANUAL_TEST_NODE_LIMIT} 个节点"}, HTTPStatus.BAD_REQUEST)
return
if not maintenance_lock.acquire(blocking=False):
self.send_json({"ok": False, "error": "当前已有连接或节点维护任务正在运行,请稍后再试"}, HTTPStatus.CONFLICT)
return
with lock:
if is_connecting:
maintenance_lock.release()
self.send_json({"ok": False, "error": "当前已有连接或节点维护任务正在运行,请稍后再试"}, HTTPStatus.CONFLICT)
return
is_connecting = True
try:
set_state(is_connecting=True, last_check_message="正在手动测试节点可用性...")
tested_nodes = test_multiple_nodes(node_ids)
self.send_json({"ok": True, "nodes": tested_nodes})
finally:
with lock:
is_connecting = False
set_state(is_connecting=False)
maintenance_lock.release()
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
elif effective_path == "/api/disconnect":
try:
cancel_background_refill()
cancel_pending_connection_attempt()
ui_cfg = load_ui_config()
ui_cfg["connection_enabled"] = False
auth_file = DATA_DIR / "ui_auth.json"
with lock:
DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg)
stop_active_openvpn()
with lock:
nodes = read_nodes()
for item in nodes:
item["active"] = False
write_json(NODES_FILE, nodes)
global last_active_ping_time, last_active_latency
last_active_ping_time = 0.0
last_active_latency = 0
global consecutive_proxy_failures, last_proxy_failure_node_id
consecutive_proxy_failures = 0
last_proxy_failure_node_id = ""
set_state(
active_openvpn_node_id="",
pending_node_id="",
last_check_message="手动断开连接",
active_node_latency="无活动连接",
proxy_ok=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error="连接已手动断开",
)
self.send_json({"ok": True})
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
elif effective_path == "/api/connect":
previous_node_id = active_openvpn_node_id if active_openvpn_running() else ""
try:
payload = self.read_json_body()
self.send_json({"ok": True, "message": connect_node(str(payload.get("id") or ""))})
except ConnectionCancelled as exc:
self.send_json({"ok": False, "cancelled": True, "error": str(exc)}, HTTPStatus.CONFLICT)
except RuntimeError as exc:
if str(exc).startswith("当前已有"):
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.CONFLICT)
return
threading.Thread(
target=recover_after_manual_connect_failure,
args=(previous_node_id,),
daemon=True,
).start()
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
except Exception as exc:
threading.Thread(
target=recover_after_manual_connect_failure,
args=(previous_node_id,),
daemon=True,
).start()
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
elif effective_path == "/api/test_node":
try:
payload = self.read_json_body()
node_id = str(payload.get("id") or "")
if not node_id.strip():
self.send_json({"ok": False, "error": "节点 ID 不能为空"}, HTTPStatus.BAD_REQUEST)
return
if not maintenance_lock.acquire(blocking=False):
self.send_json({"ok": False, "error": "当前已有连接或节点维护任务正在运行,请稍后再试"}, HTTPStatus.CONFLICT)
return
with lock:
if is_connecting:
maintenance_lock.release()
self.send_json({"ok": False, "error": "当前已有连接或节点维护任务正在运行,请稍后再试"}, HTTPStatus.CONFLICT)
return
is_connecting = True
try:
set_state(is_connecting=True, last_check_message="正在手动测试节点可用性...")
updated_node = test_node_by_id(node_id)
self.send_json({"ok": True, "node": updated_node})
finally:
with lock:
is_connecting = False
set_state(is_connecting=False)
maintenance_lock.release()
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
elif effective_path == "/api/test_proxy":
try:
self.read_request_body()
result = check_proxy_health()
if result["ok"]:
set_state(
proxy_ok=True,
proxy_ip=result["ip"],
proxy_latency_ms=result["latency_ms"],
proxy_error=""
)
else:
set_state(
proxy_ok=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error=result.get("error", "未知错误")
)
self.send_json(result)
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
else:
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
class Tee:
def __init__(self, file_path: str):
Path(file_path).parent.mkdir(exist_ok=True, parents=True)
self.file = open(file_path, "a", encoding="utf-8")
self.stdout = sys.stdout
def write(self, data: str) -> None:
self.stdout.write(data)
self.file.write(data)
self.file.flush()
def flush(self) -> None:
self.stdout.flush()
self.file.flush()
def isatty(self) -> bool:
return self.stdout.isatty()
def __getattr__(self, attr: str) -> Any:
return getattr(self.stdout, attr)
def main() -> None:
ensure_dirs()
kill_existing_openvpn_processes()
log_file = DATA_DIR / "vpngate.log"
tee = Tee(str(log_file))
sys.stdout = tee
sys.stderr = tee
write_json(
STATE_FILE,
{
"api_url": API_URL,
"mirror_url": MIRROR_HTTPS_URL,
"target_valid_nodes": TARGET_VALID_NODES,
"fetch_interval_seconds": FETCH_INTERVAL_SECONDS,
"check_interval_seconds": CHECK_INTERVAL_SECONDS,
"local_proxy": f"http://{'[' + LOCAL_PROXY_HOST + ']' if ':' in LOCAL_PROXY_HOST else LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}",
"active_openvpn_node_id": "",
"last_fetch_status": "starting",
"last_fetch_source": "",
"last_check_message": "服务已启动,正在初始化网络并获取候选 VPN 节点...",
"is_connecting": True,
"pending_node_id": "",
"active_node_latency": "正在准备",
"blacklisted_nodes": 0,
},
)
threading.Thread(target=proxy_server.start_proxy_server, args=(LOCAL_PROXY_HOST, LOCAL_PROXY_PORT), daemon=True).start()
# Wait for the gateway to officially start
print("[网关] 正在启动代理网关...", flush=True)
gateway_ready = False
is_ipv6 = ":" in LOCAL_PROXY_HOST
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
for _ in range(30):
s = None
try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(0.5)
connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""):
connect_host = "::1" if is_ipv6 else "127.0.0.1"
try:
s.connect((connect_host, LOCAL_PROXY_PORT))
gateway_ready = True
break
except Exception:
if connect_host == "::1":
try:
s.close()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.5)
s.connect(("127.0.0.1", LOCAL_PROXY_PORT))
gateway_ready = True
break
except Exception:
pass
raise
except Exception:
time.sleep(0.5)
finally:
if s is not None:
try:
s.close()
except Exception:
pass
if gateway_ready:
print("[网关] 代理网关已成功启动监听,启动同步与检测脚本...", flush=True)
else:
print("[警告] 代理网关启动超时,继续执行脚本...", flush=True)
threading.Thread(target=collector_loop, daemon=True).start()
threading.Thread(target=ip_enrichment_loop, daemon=True).start()
threading.Thread(target=background_proxy_checker, daemon=True).start()
threading.Thread(target=active_node_pinger, daemon=True).start()
ui_cfg = load_ui_config()
ui_host = ui_cfg.get("host", UI_HOST)
ui_port = bounded_int(ui_cfg.get("port"), UI_PORT, 1, 65535)
print(f"UI: http://{ui_host}:{ui_port}/", flush=True)
print(f"Proxy: http://{LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}", flush=True)
DualStackHTTPServer((ui_host, ui_port), Handler).serve_forever()
if __name__ == "__main__":
main()