fix: add recursive depth limit to auto_switch_node, improve connection status tracking, and implement UUID-based session management

This commit is contained in:
baoweise
2026-05-28 23:33:56 +08:00
parent 883c7c18ee
commit 9ca777488c
3 changed files with 341 additions and 169 deletions
+98 -78
View File
@@ -306,6 +306,9 @@ def format_line(label, value, target_width=26):
padding = " " * max(0, target_width - w)
return f"{prefix}{label}{padding}: {value}"
def print_line(text=""):
print(f"{text}\033[K")
def print_status():
cfg = load_ui_cfg()
ui_port = cfg.get("port", 8787)
@@ -336,36 +339,36 @@ def print_status():
gateway_status = f"{green}[已激活]{reset}" if gateway_ok else f"{red}[未启动]{reset}"
openvpn_status = f"{green}[已连接]{reset}" if openvpn_ok else f"{red}[未连接]{reset}"
print("=======================================================")
print(f" {bold}AimiliVPN 管理终端 v2.0{reset} ")
print("=======================================================")
print("【核心服务状态】")
print(format_line("代理网关 (Port 7928)", gateway_status))
print(format_line(f"管理后台 (Port {ui_port})", backend_status))
print(format_line("连接核心 (OpenVPN)", openvpn_status))
print_line("=======================================================")
print_line(f" {bold}AimiliVPN 管理终端 v2.0{reset} ")
print_line("=======================================================")
print_line("【核心服务状态】")
print_line(format_line("代理网关 (Port 7928)", gateway_status))
print_line(format_line(f"管理后台 (Port {ui_port})", backend_status))
print_line(format_line("连接核心 (OpenVPN)", openvpn_status))
login_ip = "127.0.0.1" if cfg.get("host") == "127.0.0.1" else get_public_ip()
print(format_line("网页登录地址", f"{yellow}http://{login_ip}:{ui_port}/{secret_path}/{reset}"))
print(format_line("网页管理账号", cfg.get("username", "未配置")))
print_line(format_line("网页登录地址", f"{yellow}http://{login_ip}:{ui_port}/{secret_path}/{reset}"))
print_line(format_line("网页管理账号", cfg.get("username", "未配置")))
curr_pwd = cfg.get("password", "")
masked_pwd = curr_pwd if len(curr_pwd) <= 4 else curr_pwd[:3] + "********" + curr_pwd[-2:]
print(format_line("网页管理密码", masked_pwd))
print()
print("【活动节点状态】")
print_line(format_line("网页管理密码", masked_pwd))
print_line()
print_line("【活动节点状态】")
if is_connecting:
connecting_msg = state.get('last_check_message') or '正在建立加密隧道并验证路由规则...'
print(format_line("节点状态", f"{yellow}{connecting_msg}{reset}"))
print_line(format_line("节点状态", f"{yellow}{connecting_msg}{reset}"))
elif active_ip:
print(format_line("节点 IP", active_ip))
print(format_line("节点地区", active_loc))
print(format_line("节点延迟 (直连测试)", latency))
print_line(format_line("节点 IP", active_ip))
print_line(format_line("节点地区", active_loc))
print_line(format_line("节点延迟 (直连测试)", latency))
else:
print(format_line("节点状态", "无活动连接"))
print()
print("【使用方法】")
print(f" export http_proxy=socks5://127.0.0.1:7928")
print(f" export https_proxy=socks5://127.0.0.1:7928")
print("=======================================================")
print_line(format_line("节点状态", "无活动连接"))
print_line()
print_line("【使用方法】")
print_line(f" export http_proxy=socks5://127.0.0.1:7928")
print_line(f" export https_proxy=socks5://127.0.0.1:7928")
print_line("=======================================================")
def start_service():
print("正在启动 AimiliVPN 服务...", flush=True)
@@ -669,18 +672,24 @@ def main():
elif cmd == "restart":
restart_service()
elif cmd == "status":
print("\033[?1049h\033[?25l\033[H\033[J", end="", flush=True)
try:
last_state = None
while True:
current_state = get_status_state()
if current_state != last_state:
print("\033[H\033[J", end="")
print("\033[H", end="")
print_status()
print("\n提示: 正在实时监控状态,自动更新。按 Ctrl+C 退出...")
print_line("\n\033[1;33m提示: 正在实时监控状态,自动更新。按任意键或 Ctrl+C 退出...\033[0m")
print("\033[J", end="", flush=True)
last_state = current_state
time.sleep(2.0)
key = getch_timeout(1.5)
if key is not None:
break
except KeyboardInterrupt:
pass
finally:
print("\033[?1049l\033[?25h", end="", flush=True)
elif cmd == "logs":
show_logs()
elif cmd == "update":
@@ -710,63 +719,74 @@ def main():
'0': ("退出终端", None)
}
last_state = None
while True:
current_state = get_status_state()
if current_state != last_state:
print("\033[H\033[J", end="")
print_status()
bold = "\033[1m"
reset = "\033[0m"
green = "\033[1;32m"
print(f"【{bold}终端指令菜单栏{reset}】")
for key in sorted(options.keys()):
if key == '0':
continue
name, _ = options[key]
print(f" {green}[{key}]{reset} {name}")
print(f" {green}[0]{reset} {options['0'][0]}")
print("=======================================================")
print("请直接输入数字键 [0-9] 快速选择执行:", end="", flush=True)
last_state = current_state
try:
key = getch()
except KeyboardInterrupt:
print()
break
if key == '\x03':
print()
break
# '0' 键默认退出终端,并打印换行符以保持 Shell 提示符整洁
if key == '0':
print()
break
# 回车键 (\r 或 \n) 用于手动刷新当前菜单与连接状态
if key in ('\r', '\n', '\x0a', '\x0d'):
pass
# Reset last_state to force redraw after any key input
# Enter alternate buffer and hide cursor
print("\033[?1049h\033[?25l\033[H\033[J", end="", flush=True)
try:
last_state = None
if key in options:
name, func = options[key]
if func is None:
print()
while True:
current_state = get_status_state()
if current_state != last_state:
print("\033[H", end="")
print_status()
bold = "\033[1m"
reset = "\033[0m"
green = "\033[1;32m"
print_line(f"【{bold}终端指令菜单栏{reset}】")
for key in sorted(options.keys()):
if key == '0':
continue
name, _ = options[key]
print_line(f" {green}[{key}]{reset} {name}")
print_line(f" {green}[0]{reset} {options['0'][0]}")
print_line("=======================================================")
print("请直接输入数字键 [0-9] 快速选择执行:\033[K", end="", flush=True)
print("\033[J", end="", flush=True)
last_state = current_state
try:
key = getch_timeout(1.0)
except KeyboardInterrupt:
break
print("\033[H\033[J", end="")
print(f"正在执行: {name}...\n")
func()
if func in (start_service, stop_service, restart_service):
if key is None:
continue
if func in (configure_web, configure_port, configure_credentials, show_logs, update_service):
if key == '\x03' or key == 'q' or key == 'Q':
break
if key == '0':
break
if key in ('\r', '\n', '\x0a', '\x0d'):
last_state = None
continue
input("\n操作已完成,按回车键返回主菜单...")
if key in options:
name, func = options[key]
if func is None:
break
# Temporarily restore normal terminal scrollback and show cursor
print("\033[?1049l\033[?25h", end="", flush=True)
print(f"正在执行: {name}...\n")
try:
func()
except Exception as e:
print(f"执行出错: {e}")
if func not in (start_service, stop_service, restart_service,
configure_web, configure_port, configure_credentials, show_logs, update_service):
input("\n操作已完成,按回车键返回主菜单...")
# Re-enter alternate buffer and hide cursor
print("\033[?1049h\033[?25l\033[H\033[J", end="", flush=True)
last_state = None
finally:
# Exit alternate buffer and show cursor on exit
print("\033[?1049l\033[?25h", end="", flush=True)
if __name__ == "__main__":
main()
+103 -1
View File
@@ -22,8 +22,103 @@ def recv_exact(sock: socket.socket, size: int) -> bytes:
data += chunk
return data
def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float = 3.0) -> str | None:
try:
socket.inet_aton(host)
return host
except OSError:
pass
import random
tx_id = random.getrandbits(16).to_bytes(2, "big")
flags = b"\x01\x00"
questions = b"\x00\x01"
rrs = b"\x00\x00\x00\x00\x00\x00"
qname = b""
for part in host.split("."):
if not part:
continue
part_bytes = part.encode("idna")
qname += len(part_bytes).to_bytes(1, "big") + part_bytes
qname += b"\x00"
qtype_qclass = b"\x00\x01\x00\x01"
packet = tx_id + flags + questions + rrs + qname + qtype_qclass
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.settimeout(timeout)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b"tun0")
except OSError:
return None
sock.sendto(packet, (dns_server, 53))
resp, _ = sock.recvfrom(2048)
except Exception:
return None
finally:
sock.close()
if len(resp) < 12:
return None
if resp[:2] != tx_id:
return None
rcode = resp[3] & 0x0F
if rcode != 0:
return None
offset = 12
while offset < len(resp):
length = resp[offset]
if length == 0:
offset += 1
break
elif (length & 0xC0) == 0xC0:
offset += 2
break
else:
offset += 1 + length
offset += 4
answers_count = int.from_bytes(resp[6:8], "big")
if answers_count == 0:
return None
for _ in range(answers_count):
if offset >= len(resp):
break
while offset < len(resp):
length = resp[offset]
if length == 0:
offset += 1
break
elif (length & 0xC0) == 0xC0:
offset += 2
break
else:
offset += 1 + length
if offset + 10 > len(resp):
break
atype = int.from_bytes(resp[offset : offset + 2], "big")
aclass = int.from_bytes(resp[offset + 2 : offset + 4], "big")
rdlength = int.from_bytes(resp[offset + 8 : offset + 10], "big")
offset += 10
if offset + rdlength > len(resp):
break
if atype == 1 and aclass == 1 and rdlength == 4:
ip_bytes = resp[offset : offset + 4]
return socket.inet_ntoa(ip_bytes)
offset += rdlength
return None
def create_connection(address: tuple[str, int], timeout: float = 20) -> socket.socket:
host, port = address
resolved_ip = resolve_dns_over_tun0(host)
if resolved_ip:
host = resolved_ip
err = None
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
@@ -76,7 +171,14 @@ def socks5_client(client: socket.socket, first_byte: bytes) -> None:
client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
return
port = int.from_bytes(recv_exact(client, 2), "big")
upstream = create_connection((host, port), timeout=20)
try:
upstream = create_connection((host, port), timeout=20)
except Exception:
try:
client.sendall(b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00")
except OSError:
pass
raise
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
relay(client, upstream)
finally:
+140 -90
View File
@@ -21,6 +21,7 @@ from pathlib import Path
from typing import Any
import concurrent.futures
import sys
import uuid
# Force socket to resolve IPv4 only to avoid slow AAAA (IPv6) DNS resolution timeouts (e.g. in WSL)
_orig_getaddrinfo = socket.getaddrinfo
@@ -56,9 +57,10 @@ STATE_FILE = DATA_DIR / "state.json"
AUTH_FILE = DATA_DIR / "vpngate_auth.txt"
lock = threading.RLock()
active_sessions: dict[str, float] = {}
active_openvpn_process: subprocess.Popen[str] | None = None
active_openvpn_node_id = ""
is_connecting = False
is_connecting = True
last_active_ping_time = 0.0
last_active_latency = 0
@@ -762,7 +764,11 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]:
return list(updated_nodes_map.values())
def auto_switch_node() -> None:
def auto_switch_node(attempt: int = 0) -> None:
if attempt >= 3:
print("[自动切换] 连续切换失败已达 3 次,停止切换以防止主线程死锁,将在后台重新加载节点...", flush=True)
return
# Find the next best available node
with lock:
nodes = read_json(NODES_FILE, [])
@@ -784,7 +790,7 @@ def auto_switch_node() -> None:
err_msg = f"切换到备用节点 {next_node['id']} 失败: {e},将尝试下一个..."
print(f"[自动切换] {err_msg}", flush=True)
log_to_json("WARNING", "VPN", err_msg)
auto_switch_node()
auto_switch_node(attempt + 1)
else:
msg = "没有可用的备选节点,将自动断开并清理当前连接状态,同时在后台异步获取新节点..."
print(f"[自动切换] {msg}", flush=True)
@@ -885,99 +891,121 @@ def connect_node(node_id: str) -> str:
is_connecting = False
def maintain_valid_nodes(force: bool = False) -> str:
global active_openvpn_process, active_openvpn_node_id
global active_openvpn_process, active_openvpn_node_id, is_connecting
ensure_dirs()
if force:
with lock:
stop_active_openvpn()
elif not active_openvpn_running():
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)
auto_switch_node()
is_connecting = True
try:
candidates = fetch_candidates()
except Exception as exc:
vpn_utils.check_and_fix_dns()
try:
candidates = fetch_candidates()
except Exception as exc2:
set_state(last_fetch_at=time.time(), last_fetch_status="error", last_fetch_message=str(exc2))
candidates = []
if not candidates:
return "没有拉取到新节点"
with lock:
active_node = None
if active_openvpn_node_id:
current_nodes = read_json(NODES_FILE, [])
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:
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)
# Test the first 10 non-active nodes from the new list
with lock:
current_nodes = read_json(NODES_FILE, [])
to_test = [n for n in current_nodes if not n.get("active")][:10]
to_test_ids = [n["id"] for n in to_test]
print(f"[维护线程] 正在检测新获取列表的前 10 个节点: {to_test_ids}", flush=True)
test_multiple_nodes(to_test_ids)
with lock:
merged = read_json(NODES_FILE, [])
if not active_openvpn_running():
available_candidates = [n for n in merged if n.get("probe_status") == "available"]
if available_candidates:
if force:
with lock:
stop_active_openvpn()
elif not active_openvpn_running():
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
valid_nodes_count = len([n for n in merged if n.get("probe_status") == "available"])
message = f"Fetched {len(candidates)} nodes. Tested first 10 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
try:
set_state(is_connecting=True, last_check_message="正在拉取最新的免费 VPN 节点列表...")
candidates = fetch_candidates()
except Exception as exc:
vpn_utils.check_and_fix_dns()
try:
set_state(is_connecting=True, last_check_message="解析失败重试:正在拉取最新的免费 VPN 节点列表...")
candidates = fetch_candidates()
except Exception as exc2:
set_state(last_fetch_at=time.time(), last_fetch_status="error", last_fetch_message=str(exc2))
candidates = []
if not candidates:
is_connecting = False
return "没有拉取到新节点"
with lock:
active_node = None
if active_openvpn_node_id:
current_nodes = read_json(NODES_FILE, [])
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:
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)
# Test the first 10 non-active nodes from the new list
with lock:
current_nodes = read_json(NODES_FILE, [])
to_test = [n for n in current_nodes if not n.get("active")][:10]
to_test_ids = [n["id"] for n in to_test]
print(f"[维护线程] 正在检测新获取列表的前 10 个节点: {to_test_ids}", flush=True)
set_state(is_connecting=True, last_check_message="正在并发检测筛选可用节点,这可能需要 5-30 秒...")
test_multiple_nodes(to_test_ids)
is_connecting = False
with lock:
merged = read_json(NODES_FILE, [])
if not active_openvpn_running():
available_candidates = [n for n in merged if n.get("probe_status") == "available"]
if available_candidates:
auto_switch_node()
valid_nodes_count = len([n for n in merged if n.get("probe_status") == "available"])
message = f"Fetched {len(candidates)} nodes. Tested first 10 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:
is_connecting = False
raise e
def collector_loop() -> None:
while True:
success = False
try:
maintain_valid_nodes(force=False)
res = maintain_valid_nodes(force=False)
if "没有拉取到新节点" not in res:
success = True
except Exception as exc:
set_state(last_check_at=time.time(), last_check_message=f"check error: {exc}")
time.sleep(CHECK_INTERVAL_SECONDS)
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">
@@ -3177,7 +3205,6 @@ class Handler(BaseHTTPRequestHandler):
def is_authorized(self) -> bool:
ui_cfg = load_ui_config()
pwd = ui_cfg.get("password")
uname = ui_cfg.get("username", "admin")
if not pwd:
return True
@@ -3190,8 +3217,15 @@ class Handler(BaseHTTPRequestHandler):
k, v = item.split("=", 1)
cookies[k.strip()] = v.strip()
expected_token = get_session_token(pwd, uname)
return cookies.get("session") == expected_token
session_token = cookies.get("session")
if not session_token:
return False
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()
@@ -3299,7 +3333,9 @@ class Handler(BaseHTTPRequestHandler):
expected_uname = ui_cfg.get("username", "admin")
if expected_pwd and input_pwd == expected_pwd and input_uname == expected_uname:
token = get_session_token(expected_pwd, expected_uname)
token = uuid.uuid4().hex
with lock:
active_sessions[token] = time.time() + 30 * 24 * 3600
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
secret_path = self.get_secret_path()
@@ -3315,6 +3351,18 @@ class Handler(BaseHTTPRequestHandler):
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 "/"
self.send_response(HTTPStatus.OK)
@@ -3502,7 +3550,9 @@ def main() -> None:
"local_proxy": f"http://{LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}",
"active_openvpn_node_id": "",
"last_fetch_status": "starting",
"last_check_message": "service starting",
"last_check_message": "服务已启动,正在初始化网络并获取候选 VPN 节点...",
"is_connecting": True,
"active_node_latency": "正在准备",
"blacklisted_nodes": 0,
},
)