diff --git a/install.sh b/install.sh index f3c5024..a0f8427 100644 --- a/install.sh +++ b/install.sh @@ -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() diff --git a/proxy_server.py b/proxy_server.py index 398b903..1445953 100644 --- a/proxy_server.py +++ b/proxy_server.py @@ -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: diff --git a/vpngate_manager.py b/vpngate_manager.py index 787bb10..22720c3 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -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""" @@ -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, }, )