diff --git a/vpngate_manager.py b/vpngate_manager.py index 6e51be5..4f61c2f 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -110,6 +110,8 @@ 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) 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) 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") @@ -133,7 +135,7 @@ maintenance_lock = threading.Lock() active_sessions: dict[str, float] = {} active_openvpn_process: subprocess.Popen[str] | None = None active_openvpn_node_id = "" -is_connecting = True +is_connecting = False last_active_ping_time = 0.0 last_active_latency = 0 @@ -225,7 +227,7 @@ def load_ui_config() -> dict[str, Any]: "connection_enabled": True, "fixed_node_id": "", "favorite_node_ids": [], - "fav_fail_fallback": True + "fav_fail_fallback": False } updated = False if auth_file.exists(): @@ -265,7 +267,7 @@ def load_ui_config() -> dict[str, Any]: if not auth_file.exists() or updated: try: DATA_DIR.mkdir(exist_ok=True, parents=True) - auth_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + write_json(auth_file, config) except Exception: pass @@ -376,7 +378,7 @@ def get_state() -> dict[str, Any]: 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["fav_fail_fallback"] = ui_cfg.get("fav_fail_fallback", True) + state["fav_fail_fallback"] = False return state @@ -1169,6 +1171,146 @@ def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: ) return available_nodes + untested_nodes + unavailable_nodes +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() @@ -1386,29 +1528,7 @@ def auto_switch_node(attempt: int = 0) -> None: if n.get("probe_status") == "available" and not n.get("active") ] - - if routing_mode == "fixed_region" and target_country: - candidates = [ - n for n in candidates - if n.get("country") == target_country - or vpn_utils.COUNTRY_TRANSLATIONS.get(n.get("country", ""), n.get("country", "")) == target_country - ] - if routing_mode == "favorites": - fav_ids = set(ui_cfg.get("favorite_node_ids", [])) - fav_candidates = [n for n in candidates if n.get("id") in fav_ids] - if fav_candidates: - candidates = fav_candidates - else: - fav_fail_fallback = ui_cfg.get("fav_fail_fallback", True) - if not fav_fail_fallback: - candidates = [] - - # Apply routing_ip_type filter - 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")] - elif routing_ip_type == "hosting": - candidates = [n for n in candidates if n.get("ip_type") == "hosting"] + 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")))) @@ -1440,8 +1560,10 @@ def auto_switch_node(attempt: int = 0) -> None: def bg_fetch_and_switch(): try: + # 避免所有节点不可用时连续拉取/测试导致 CPU 与 tun 网卡风暴。 + time.sleep(60) maintain_valid_nodes(force=False) - auto_switch_node() + auto_switch_node(attempt + 1) except Exception as e: print(f"[自动切换后台补齐] 获取并测试节点失败: {e}", flush=True) @@ -1469,13 +1591,14 @@ def connect_node(node_id: str) -> str: raise ValueError(f"Node not found: {node_id}") ui_cfg = load_ui_config() + validate_node_allowed_by_routing(node, ui_cfg) ui_cfg["connection_enabled"] = True if ui_cfg.get("routing_mode") == "fixed_ip": ui_cfg["fixed_node_id"] = node_id auth_file = DATA_DIR / "ui_auth.json" with lock: DATA_DIR.mkdir(exist_ok=True, parents=True) - auth_file.write_text(json.dumps(ui_cfg, ensure_ascii=False, indent=2), encoding="utf-8") + write_json(auth_file, ui_cfg) set_state(active_node_latency="清理连接", last_check_message="正在关闭与清理旧的 VPN 连接及网卡...") stop_active_openvpn() @@ -1576,28 +1699,25 @@ def maintain_valid_nodes(force: bool = False) -> str: msg = "节点维护任务正在运行,请稍后再试" set_state(last_check_message=msg) return msg - is_connecting = True + with lock: + if is_connecting: + maintenance_lock.release() + msg = "当前已有连接或节点测试任务正在运行,请稍后再试" + set_state(last_check_message=msg) + return msg + is_connecting = True try: if force: with lock: stop_active_openvpn() + reconnect_fixed_node_if_needed(load_ui_config()) elif 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": - target_id = active_openvpn_node_id or ui_cfg.get("fixed_node_id", "") - if target_id: - nodes = read_nodes() - if any(n.get("id") == target_id for n in nodes): - print(f"[维护线程] 检测到固定 IP 模式下 OpenVPN 未运行,正在重新拉起同一节点: {target_id}", flush=True) - is_connecting = False - try: - connect_node(target_id) - except Exception as e: - print(f"[维护线程] 重新拉起固定节点 {target_id} 失败: {e}", flush=True) - is_connecting = True + reconnect_fixed_node_if_needed(ui_cfg) else: has_active_id = False with lock: @@ -1656,10 +1776,66 @@ def maintain_valid_nodes(force: bool = False) -> str: write_json(NODES_FILE, merged) - # Test all non-active nodes from the list + initial_tested_ids: set[str] = set() + 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: + initial_tested_ids = set(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) + test_multiple_nodes(fast_test_ids) + + 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_test_ids)} 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 + + # 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")] + to_test = [ + n for n in current_nodes + if not n.get("active") and n.get("id") not in initial_tested_ids + ] to_test_ids = [n["id"] for n in to_test] msg = f"开始对列表中所有候选节点进行周期连通性与延迟测试,待检测节点共 {len(to_test_ids)} 个" @@ -1697,32 +1873,10 @@ def maintain_valid_nodes(force: bool = False) -> str: connection_enabled = ui_cfg.get("connection_enabled", True) if connection_enabled: routing_mode = ui_cfg.get("routing_mode", "auto") - target_country = ui_cfg.get("force_country", "") if routing_mode != "fixed_ip": available_candidates = [n for n in merged if n.get("probe_status") == "available"] - if routing_mode == "fixed_region" and target_country: - available_candidates = [ - n for n in available_candidates - if n.get("country") == target_country - or vpn_utils.COUNTRY_TRANSLATIONS.get(n.get("country", ""), n.get("country", "")) == target_country - ] - elif routing_mode == "favorites": - fav_ids = set(ui_cfg.get("favorite_node_ids", [])) - fav_candidates = [n for n in available_candidates if n.get("id") in fav_ids] - if fav_candidates: - available_candidates = fav_candidates - else: - fav_fail_fallback = ui_cfg.get("fav_fail_fallback", True) - if not fav_fail_fallback: - available_candidates = [] - - # Apply routing_ip_type filter for auto-connect - routing_ip_type = ui_cfg.get("routing_ip_type", "all") - if routing_ip_type == "residential": - available_candidates = [n for n in available_candidates if n.get("ip_type") in ("residential", "mobile")] - elif routing_ip_type == "hosting": - available_candidates = [n for n in available_candidates if n.get("ip_type") == "hosting"] + available_candidates = apply_routing_filters(available_candidates, ui_cfg) if available_candidates: auto_switch_node() @@ -3053,15 +3207,8 @@ INDEX_HTML = r"""