mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-05 23:56:55 +08:00
Improve node fetching and connection failover
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
name: Update VPNGate mirror
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/update-vpngate-mirror.yml"
|
||||
- "scripts/build_vpngate_mirror.py"
|
||||
- "snapshot_utils.py"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: vpngate-pages-mirror
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Fetch and validate VPNGate snapshot
|
||||
run: python scripts/build_vpngate_mirror.py --output-dir _site
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: _site
|
||||
- name: Deploy GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -56,6 +56,7 @@ AimiliVPN_Analysis_ZH.md
|
||||
|
||||
# ---- 临时和测试文件 ----
|
||||
scratch/
|
||||
_site/
|
||||
*连接配置*
|
||||
test_*.py
|
||||
*.bak
|
||||
|
||||
@@ -117,6 +117,16 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
|
||||
* **设置上游代理**:如果您有其他可用的代理服务,可在网页管理面板中打开“管理员 -> 代理及网络设置”,配置有效的 HTTP/SOCKS5 上游代理,后台会自动通过该代理拉取更新。
|
||||
* **修改 DNS 解析器**:在终端修改 `/etc/resolv.conf`,将域名服务器替换为公共 DNS(如 `nameserver 8.8.8.8` 和 `nameserver 1.1.1.1`)。
|
||||
|
||||
程序会按以下顺序自动回退,不需要用户手动切换:
|
||||
|
||||
1. VPNGate 官方 HTTPS
|
||||
2. VPNGate 官方 HTTP(兼容旧系统,结果不会覆盖 HTTPS 获得的可信缓存)
|
||||
3. GitHub Pages 镜像 HTTPS
|
||||
4. GitHub Pages 镜像 HTTP
|
||||
5. VPS 本地最近有效快照;首次安装时使用仓库附带的初始快照
|
||||
|
||||
默认镜像为 `https://baoweise-bot.github.io/aimili-vpngate/vpngate.csv`。仓库管理员需要在 GitHub 的 **Settings -> Pages** 中将 Source 设置为 **GitHub Actions**,定时工作流会每 15 分钟校验并发布一次快照。可通过 `VPNGATE_API_HTTPS_URL`、`VPNGATE_API_HTTP_URL`、`VPNGATE_MIRROR_HTTPS_URL` 和 `VPNGATE_MIRROR_HTTP_URL` 覆盖各节点源。
|
||||
|
||||
#### 4. VPN 已成功连接,但客户端设置代理后无法上网 (无流量)
|
||||
* **原因**:部分系统启用了严格的反向路径过滤(`rp_filter`),导致策略路由的入站/出站数据包被系统误判丢弃。
|
||||
* **解决办法**:在终端输入 `ml` 命令打开交互菜单,工具会自动检测并提示您将 `rp_filter` 修复为宽松模式(值为 `2`)。
|
||||
@@ -222,6 +232,10 @@ To prevent unauthorized scanning and abuse of the proxy port on the public inter
|
||||
* **Reason**: The official VPNGate domain is blocked or DNS resolution failed on your VPS.
|
||||
* **Solution**: Add an HTTP/SOCKS5 upstream proxy in the settings panel (Admin -> Proxy Settings), or configure public DNS in `/etc/resolv.conf` (e.g., `nameserver 8.8.8.8`).
|
||||
|
||||
The application automatically tries the official HTTPS endpoint, official HTTP endpoint, GitHub Pages HTTPS mirror, GitHub Pages HTTP mirror, and finally the last valid local snapshot. A validated initial snapshot is bundled for first startup. HTTP results remain supported for older systems but do not replace the cache obtained through HTTPS.
|
||||
|
||||
The default mirror is `https://baoweise-bot.github.io/aimili-vpngate/vpngate.csv`. Repository administrators must select **GitHub Actions** as the Pages source under **Settings -> Pages**. The scheduled workflow validates and publishes a fresh snapshot every 15 minutes. Source URLs can be overridden with `VPNGATE_API_HTTPS_URL`, `VPNGATE_API_HTTP_URL`, `VPNGATE_MIRROR_HTTPS_URL`, and `VPNGATE_MIRROR_HTTP_URL`.
|
||||
|
||||
---
|
||||
|
||||
### 🎁 Donation Support
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": "https://www.vpngate.net/api/iphone/",
|
||||
"generated_at": 1787744193.269409,
|
||||
"generated_at_iso": "2026-08-26T11:36:33.269409+00:00",
|
||||
"row_count": 99,
|
||||
"byte_count": 1333954,
|
||||
"sha256": "7f905365087080b2c5bfe5eabfdb74aa8aa18b23e65b87f5409e93ef1156a6a4"
|
||||
}
|
||||
+2
-2
@@ -459,9 +459,9 @@ def start_proxy_server(host: str, port: int) -> None:
|
||||
pass
|
||||
continue
|
||||
|
||||
def run_client() -> None:
|
||||
def run_client(client_socket: socket.socket = client, client_address: tuple[str, int] = address) -> None:
|
||||
try:
|
||||
proxy_client(client, address)
|
||||
proxy_client(client_socket, client_address)
|
||||
finally:
|
||||
proxy_connection_sem.release()
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import snapshot_utils
|
||||
|
||||
|
||||
DEFAULT_SOURCE = "https://www.vpngate.net/api/iphone/"
|
||||
|
||||
|
||||
def fetch_snapshot(url: str, timeout: int) -> str:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "AimiliVPN-Mirror/1.0",
|
||||
"Accept": "text/plain,*/*",
|
||||
},
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
if getattr(response, "status", 200) != 200:
|
||||
raise RuntimeError(f"VPNGate returned HTTP {response.status}")
|
||||
while True:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > snapshot_utils.MAX_SNAPSHOT_BYTES:
|
||||
raise RuntimeError("VPNGate response exceeds the size limit")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode("utf-8", errors="strict")
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
temp_path.write_bytes(content)
|
||||
temp_path.replace(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build a validated VPNGate mirror snapshot")
|
||||
parser.add_argument("--output-dir", default="mirror")
|
||||
parser.add_argument("--source", default=os.environ.get("VPNGATE_API_HTTPS_URL", DEFAULT_SOURCE))
|
||||
parser.add_argument("--timeout", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
|
||||
text = fetch_snapshot(args.source, args.timeout)
|
||||
summary = snapshot_utils.snapshot_summary(text)
|
||||
encoded = text.encode("utf-8")
|
||||
now = time.time()
|
||||
metadata = {
|
||||
"schema_version": 1,
|
||||
"source": args.source,
|
||||
"generated_at": now,
|
||||
"generated_at_iso": datetime.fromtimestamp(now, timezone.utc).isoformat(),
|
||||
"row_count": summary["row_count"],
|
||||
"byte_count": summary["byte_count"],
|
||||
"sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir).resolve()
|
||||
atomic_write(output_dir / "vpngate.csv", encoded)
|
||||
atomic_write(
|
||||
output_dir / "vpngate.meta.json",
|
||||
(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
||||
)
|
||||
print(json.dumps(metadata, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_SNAPSHOT_BYTES = 12 * 1024 * 1024
|
||||
MAX_CONFIG_BYTES = 128 * 1024
|
||||
|
||||
REQUIRED_COLUMNS = {
|
||||
"HostName",
|
||||
"IP",
|
||||
"Score",
|
||||
"Ping",
|
||||
"Speed",
|
||||
"CountryLong",
|
||||
"CountryShort",
|
||||
"NumVpnSessions",
|
||||
"OpenVPN_ConfigData_Base64",
|
||||
}
|
||||
|
||||
# VPNGate profiles are data, but OpenVPN profiles can also launch programs.
|
||||
# Keep the accepted surface limited to connection and TLS settings.
|
||||
SAFE_DIRECTIVES = {
|
||||
"auth",
|
||||
"cipher",
|
||||
"client",
|
||||
"comp-lzo",
|
||||
"compress",
|
||||
"connect-retry",
|
||||
"connect-retry-max",
|
||||
"connect-timeout",
|
||||
"data-ciphers",
|
||||
"data-ciphers-fallback",
|
||||
"dev",
|
||||
"dev-type",
|
||||
"dhcp-option",
|
||||
"explicit-exit-notify",
|
||||
"keepalive",
|
||||
"key-direction",
|
||||
"mute",
|
||||
"nobind",
|
||||
"persist-key",
|
||||
"persist-tun",
|
||||
"ping",
|
||||
"ping-restart",
|
||||
"ping-timer-rem",
|
||||
"proto",
|
||||
"pull",
|
||||
"rcvbuf",
|
||||
"remote",
|
||||
"remote-cert-tls",
|
||||
"remote-random",
|
||||
"remote-random-hostname",
|
||||
"reneg-sec",
|
||||
"resolv-retry",
|
||||
"route-delay",
|
||||
"sndbuf",
|
||||
"tls-cipher",
|
||||
"tls-ciphersuites",
|
||||
"tls-client",
|
||||
"tls-version-min",
|
||||
"verb",
|
||||
"verify-x509-name",
|
||||
}
|
||||
SAFE_INLINE_BLOCKS = {"ca", "cert", "key", "tls-auth", "tls-crypt"}
|
||||
|
||||
|
||||
def decode_config(encoded: str) -> str:
|
||||
compact = "".join(str(encoded or "").split())
|
||||
if not compact:
|
||||
raise ValueError("OpenVPN configuration is empty")
|
||||
raw = base64.b64decode(compact.encode("ascii"), validate=True)
|
||||
if not raw or len(raw) > MAX_CONFIG_BYTES:
|
||||
raise ValueError("OpenVPN configuration size is invalid")
|
||||
return raw.decode("utf-8", errors="strict")
|
||||
|
||||
|
||||
def validate_openvpn_config(config_text: str) -> None:
|
||||
if not config_text or len(config_text.encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
raise ValueError("OpenVPN configuration size is invalid")
|
||||
if "\x00" in config_text:
|
||||
raise ValueError("OpenVPN configuration contains a NUL byte")
|
||||
|
||||
directives: set[str] = set()
|
||||
current_block: str | None = None
|
||||
remote_seen = False
|
||||
for raw_line in config_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(("#", ";")):
|
||||
continue
|
||||
if line.startswith("<") and line.endswith(">"):
|
||||
tag = line[1:-1].strip().lower()
|
||||
if tag.startswith("/"):
|
||||
if current_block != tag[1:]:
|
||||
raise ValueError(f"Unexpected closing OpenVPN block: {tag}")
|
||||
current_block = None
|
||||
continue
|
||||
if current_block is not None or tag not in SAFE_INLINE_BLOCKS:
|
||||
raise ValueError(f"Unsafe OpenVPN inline block: {tag}")
|
||||
current_block = tag
|
||||
directives.add(f"<{tag}>")
|
||||
continue
|
||||
if current_block is not None:
|
||||
continue
|
||||
|
||||
parts = re.split(r"\s+", line)
|
||||
directive = parts[0].lstrip("-").lower()
|
||||
if directive not in SAFE_DIRECTIVES:
|
||||
raise ValueError(f"Unsafe OpenVPN directive: {directive}")
|
||||
directives.add(directive)
|
||||
|
||||
if directive == "remote":
|
||||
if len(parts) < 3:
|
||||
raise ValueError("OpenVPN remote directive is incomplete")
|
||||
try:
|
||||
port = int(parts[2])
|
||||
except ValueError as exc:
|
||||
raise ValueError("OpenVPN remote port is invalid") from exc
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("OpenVPN remote port is out of range")
|
||||
remote_seen = True
|
||||
elif directive == "dev" and (len(parts) < 2 or not re.fullmatch(r"tun\d*", parts[1].lower())):
|
||||
raise ValueError("Only TUN OpenVPN devices are accepted")
|
||||
|
||||
if current_block is not None:
|
||||
raise ValueError(f"Unclosed OpenVPN inline block: {current_block}")
|
||||
if "client" not in directives or not remote_seen:
|
||||
raise ValueError("OpenVPN client or remote directive is missing")
|
||||
if not {"<ca>", "<cert>", "<key>"}.issubset(directives):
|
||||
raise ValueError("OpenVPN certificate blocks are incomplete")
|
||||
|
||||
|
||||
def parse_and_validate_snapshot(text: str, max_rows: int | None = None) -> list[dict[str, str]]:
|
||||
raw_size = len(text.encode("utf-8"))
|
||||
if raw_size <= 0 or raw_size > MAX_SNAPSHOT_BYTES:
|
||||
raise ValueError("VPNGate snapshot size is invalid")
|
||||
|
||||
lines = [line for line in text.splitlines() if line and not line.startswith("*")]
|
||||
if not lines:
|
||||
raise ValueError("VPNGate snapshot is empty")
|
||||
if lines[0].startswith("#"):
|
||||
lines[0] = lines[0][1:]
|
||||
|
||||
reader = csv.DictReader(io.StringIO("\n".join(lines)))
|
||||
fieldnames = set(reader.fieldnames or [])
|
||||
if not REQUIRED_COLUMNS.issubset(fieldnames):
|
||||
raise ValueError("VPNGate snapshot columns are incomplete")
|
||||
|
||||
rows: list[dict[str, str]] = []
|
||||
for row in reader:
|
||||
normalized = {str(key): str(value or "") for key, value in row.items() if key is not None}
|
||||
if not normalized.get("IP") or not normalized.get("OpenVPN_ConfigData_Base64"):
|
||||
continue
|
||||
try:
|
||||
config_text = decode_config(normalized["OpenVPN_ConfigData_Base64"])
|
||||
validate_openvpn_config(config_text)
|
||||
except (UnicodeError, ValueError):
|
||||
continue
|
||||
rows.append(normalized)
|
||||
if max_rows is not None and len(rows) >= max_rows:
|
||||
break
|
||||
if not rows:
|
||||
raise ValueError("VPNGate snapshot contains no valid nodes")
|
||||
return rows
|
||||
|
||||
|
||||
def snapshot_summary(text: str) -> dict[str, Any]:
|
||||
rows = parse_and_validate_snapshot(text)
|
||||
return {"row_count": len(rows), "byte_count": len(text.encode("utf-8"))}
|
||||
@@ -0,0 +1,477 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import proxy_server
|
||||
import snapshot_utils
|
||||
|
||||
_import_data_dir = tempfile.TemporaryDirectory()
|
||||
_original_data_dir = os.environ.get("VPNGATE_DATA_DIR")
|
||||
os.environ["VPNGATE_DATA_DIR"] = _import_data_dir.name
|
||||
try:
|
||||
import vpngate_manager as manager
|
||||
finally:
|
||||
if _original_data_dir is None:
|
||||
os.environ.pop("VPNGATE_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["VPNGATE_DATA_DIR"] = _original_data_dir
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self) -> None:
|
||||
self.running = True
|
||||
self.terminated = False
|
||||
|
||||
def poll(self):
|
||||
return None if self.running else 0
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
self.running = False
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.running = False
|
||||
return 0
|
||||
|
||||
def kill(self) -> None:
|
||||
self.running = False
|
||||
|
||||
|
||||
def valid_snapshot(ip: str = "198.51.100.10") -> str:
|
||||
config_text = (
|
||||
"client\n"
|
||||
"dev tun\n"
|
||||
"proto udp\n"
|
||||
f"remote {ip} 1194 udp\n"
|
||||
"resolv-retry infinite\n"
|
||||
"nobind\n"
|
||||
"<ca>\nCA\n</ca>\n"
|
||||
"<cert>\nCERT\n</cert>\n"
|
||||
"<key>\nKEY\n</key>\n"
|
||||
)
|
||||
config = base64.b64encode(config_text.encode("utf-8")).decode("ascii")
|
||||
return (
|
||||
"#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,OpenVPN_ConfigData_Base64\n"
|
||||
f"vpn.example,{ip},100,20,1000,Japan,JP,1,{config}\n"
|
||||
)
|
||||
|
||||
|
||||
class ManagerLogicTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp_dir.name)
|
||||
self.path_patches = [
|
||||
mock.patch.object(manager, "DATA_DIR", root),
|
||||
mock.patch.object(manager, "CONFIG_DIR", root / "configs"),
|
||||
mock.patch.object(manager, "NODES_FILE", root / "nodes.json"),
|
||||
mock.patch.object(manager, "STATE_FILE", root / "state.json"),
|
||||
mock.patch.object(manager, "AUTH_FILE", root / "auth.txt"),
|
||||
mock.patch.object(manager, "BLACKLIST_FILE", root / "blacklist.json"),
|
||||
mock.patch.object(manager, "API_CACHE_FILE", root / "api_snapshot.csv"),
|
||||
mock.patch.object(manager, "API_CACHE_META_FILE", root / "api_snapshot.meta.json"),
|
||||
mock.patch.object(manager, "BUNDLED_SNAPSHOT_FILE", root / "bundled_snapshot.csv"),
|
||||
]
|
||||
for patcher in self.path_patches:
|
||||
patcher.start()
|
||||
manager.ensure_dirs()
|
||||
manager.active_openvpn_process = None
|
||||
manager.pending_openvpn_process = None
|
||||
manager.active_openvpn_node_id = ""
|
||||
manager.active_connection_cancel_event = None
|
||||
manager.is_connecting = False
|
||||
manager.consecutive_proxy_failures = 0
|
||||
manager.last_proxy_failure_node_id = ""
|
||||
manager.background_refill_thread = None
|
||||
manager.background_refill_cancel_event.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if manager.connection_attempt_lock.locked():
|
||||
manager.connection_attempt_lock.release()
|
||||
manager.background_refill_cancel_event.set()
|
||||
manager.background_refill_thread = None
|
||||
for patcher in reversed(self.path_patches):
|
||||
patcher.stop()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def write_nodes(self, count: int) -> list[dict]:
|
||||
nodes = []
|
||||
for index in range(count):
|
||||
node_id = f"node-{index}"
|
||||
nodes.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"ip": f"192.0.2.{index + 1}",
|
||||
"remote_host": f"192.0.2.{index + 1}",
|
||||
"remote_port": 1194,
|
||||
"ping": index + 1,
|
||||
"score": 1000 - index,
|
||||
"config_text": "client\nremote 192.0.2.1 1194 udp\n",
|
||||
"config_file": str(manager.CONFIG_DIR / f"{node_id}.ovpn"),
|
||||
"probe_status": "not_checked",
|
||||
"probed_at": 0,
|
||||
"active": False,
|
||||
}
|
||||
)
|
||||
manager.write_json(manager.NODES_FILE, nodes)
|
||||
return nodes
|
||||
|
||||
def test_node_probe_stops_after_target_batch(self) -> None:
|
||||
nodes = self.write_nodes(12)
|
||||
calls = []
|
||||
|
||||
def fake_openvpn(config_file, **kwargs):
|
||||
calls.append(config_file)
|
||||
return True, "ready", None
|
||||
|
||||
with (
|
||||
mock.patch.object(manager.vpn_utils, "ping_latency_ms", return_value=10),
|
||||
mock.patch.object(manager.vpn_utils, "enrich_ip_info"),
|
||||
mock.patch.object(manager, "run_openvpn_until_ready", side_effect=fake_openvpn),
|
||||
mock.patch.object(manager, "NODE_PROBE_WORKERS", 5),
|
||||
):
|
||||
results = manager.test_multiple_nodes(
|
||||
[node["id"] for node in nodes],
|
||||
target_available=3,
|
||||
)
|
||||
|
||||
self.assertEqual(5, len(calls))
|
||||
self.assertEqual(5, len(results))
|
||||
stored = manager.read_nodes()
|
||||
self.assertEqual(5, sum(node.get("probe_status") == "available" for node in stored))
|
||||
self.assertEqual(7, sum(node.get("probe_status") == "not_checked" for node in stored))
|
||||
|
||||
def test_node_probe_stops_after_systemic_openvpn_failure(self) -> None:
|
||||
nodes = self.write_nodes(12)
|
||||
|
||||
with (
|
||||
mock.patch.object(manager.vpn_utils, "ping_latency_ms", return_value=10),
|
||||
mock.patch.object(
|
||||
manager,
|
||||
"run_openvpn_until_ready",
|
||||
return_value=(False, "[ERR_OVPN_TUN_NOT_AVAILABLE] missing TUN", None),
|
||||
) as openvpn_mock,
|
||||
mock.patch.object(manager, "NODE_PROBE_WORKERS", 5),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
results = manager.test_multiple_nodes(
|
||||
[node["id"] for node in nodes],
|
||||
target_available=3,
|
||||
)
|
||||
|
||||
self.assertEqual(5, openvpn_mock.call_count)
|
||||
self.assertEqual(5, len(results))
|
||||
stored = manager.read_nodes()
|
||||
self.assertEqual(5, sum(node.get("probe_status") == "unavailable" for node in stored))
|
||||
self.assertEqual(7, sum(node.get("probe_status") == "not_checked" for node in stored))
|
||||
|
||||
def test_maintenance_does_not_start_second_batch_after_systemic_failure(self) -> None:
|
||||
candidates = self.write_nodes(12)
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_candidates", return_value=candidates),
|
||||
mock.patch.object(manager.vpn_utils, "ping_latency_ms", return_value=10),
|
||||
mock.patch.object(
|
||||
manager,
|
||||
"run_openvpn_until_ready",
|
||||
return_value=(False, "[ERR_OVPN_CMD_NOT_FOUND] openvpn missing", None),
|
||||
) as openvpn_mock,
|
||||
mock.patch.object(manager, "NODE_PROBE_WORKERS", 5),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
result = manager.maintain_valid_nodes()
|
||||
|
||||
self.assertEqual(5, openvpn_mock.call_count)
|
||||
self.assertIn("Tested 5", result)
|
||||
|
||||
def test_cancel_pending_connection_stops_handshake_process(self) -> None:
|
||||
process = FakeProcess()
|
||||
event = threading.Event()
|
||||
manager.pending_openvpn_process = process
|
||||
manager.active_connection_cancel_event = event
|
||||
manager.is_connecting = True
|
||||
previous_epoch = manager.connection_epoch
|
||||
|
||||
manager.cancel_pending_connection_attempt()
|
||||
|
||||
self.assertTrue(event.is_set())
|
||||
self.assertTrue(process.terminated)
|
||||
self.assertIsNone(manager.pending_openvpn_process)
|
||||
self.assertFalse(manager.is_connecting)
|
||||
self.assertEqual(previous_epoch + 1, manager.connection_epoch)
|
||||
|
||||
def test_proxy_failures_reset_when_node_changes(self) -> None:
|
||||
self.assertEqual(1, manager.record_proxy_failure("node-a"))
|
||||
self.assertEqual(2, manager.record_proxy_failure("node-a"))
|
||||
self.assertEqual(1, manager.record_proxy_failure("node-b"))
|
||||
manager.reset_proxy_failure_counter("node-b")
|
||||
self.assertEqual(1, manager.record_proxy_failure("node-b"))
|
||||
|
||||
def test_failed_switch_preflight_keeps_current_connection(self) -> None:
|
||||
nodes = self.write_nodes(2)
|
||||
nodes[0]["active"] = True
|
||||
manager.write_json(manager.NODES_FILE, nodes)
|
||||
current_process = FakeProcess()
|
||||
manager.active_openvpn_process = current_process
|
||||
manager.active_openvpn_node_id = nodes[0]["id"]
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
manager,
|
||||
"run_openvpn_until_ready",
|
||||
return_value=(False, "preflight failed", None),
|
||||
),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "已保留当前连接"):
|
||||
manager.connect_node(nodes[1]["id"])
|
||||
|
||||
self.assertIs(manager.active_openvpn_process, current_process)
|
||||
self.assertEqual(nodes[0]["id"], manager.active_openvpn_node_id)
|
||||
self.assertTrue(current_process.running)
|
||||
stored = {node["id"]: node for node in manager.read_nodes()}
|
||||
self.assertEqual("unavailable", stored[nodes[1]["id"]]["probe_status"])
|
||||
|
||||
def test_proxy_failure_does_not_report_connection_success(self) -> None:
|
||||
nodes = self.write_nodes(1)
|
||||
process = FakeProcess()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
manager,
|
||||
"run_openvpn_until_ready",
|
||||
return_value=(True, "ready", process),
|
||||
),
|
||||
mock.patch.object(manager, "setup_policy_routing", return_value=False),
|
||||
mock.patch.object(manager, "cleanup_policy_routing"),
|
||||
mock.patch.object(manager.vpn_utils, "ping_latency_ms", return_value=10),
|
||||
mock.patch.object(manager, "check_proxy_health", return_value={"ok": False, "error": "no route"}),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "代理出口不可用"):
|
||||
manager.connect_node(nodes[0]["id"])
|
||||
|
||||
self.assertFalse(process.running)
|
||||
self.assertIsNone(manager.active_openvpn_process)
|
||||
self.assertEqual("", manager.active_openvpn_node_id)
|
||||
stored = manager.read_nodes()
|
||||
self.assertEqual("unavailable", stored[0]["probe_status"])
|
||||
|
||||
def test_manual_failure_recovery_prefers_previous_node(self) -> None:
|
||||
with (
|
||||
mock.patch.object(manager, "active_openvpn_running", return_value=False),
|
||||
mock.patch.object(manager, "connect_node", return_value="connected") as connect_mock,
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
mock.patch.object(manager, "auto_switch_node") as auto_switch_mock,
|
||||
):
|
||||
manager.recover_after_manual_connect_failure("old-node")
|
||||
|
||||
connect_mock.assert_called_once_with("old-node")
|
||||
auto_switch_mock.assert_not_called()
|
||||
|
||||
def test_physical_interface_detection_is_cached(self) -> None:
|
||||
original_cache = manager.vpn_utils.physical_interface_cache
|
||||
manager.vpn_utils.physical_interface_cache = (None, 0.0)
|
||||
try:
|
||||
with mock.patch.object(
|
||||
manager.vpn_utils,
|
||||
"_detect_physical_interface",
|
||||
return_value="eth0",
|
||||
) as detect_mock:
|
||||
self.assertEqual("eth0", manager.vpn_utils.get_physical_interface())
|
||||
self.assertEqual("eth0", manager.vpn_utils.get_physical_interface())
|
||||
detect_mock.assert_called_once_with()
|
||||
finally:
|
||||
manager.vpn_utils.physical_interface_cache = original_cache
|
||||
|
||||
def test_forced_refresh_keeps_healthy_active_connection(self) -> None:
|
||||
process = FakeProcess()
|
||||
manager.active_openvpn_process = process
|
||||
manager.active_openvpn_node_id = "active-node"
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_candidates", return_value=[]),
|
||||
mock.patch.object(manager, "stop_active_openvpn") as stop_mock,
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
result = manager.maintain_valid_nodes(force=True)
|
||||
|
||||
self.assertEqual("没有拉取到新节点", result)
|
||||
self.assertTrue(process.running)
|
||||
stop_mock.assert_not_called()
|
||||
|
||||
def test_fetch_timeout_skips_insecure_https_retry(self) -> None:
|
||||
csv_text = valid_snapshot()
|
||||
|
||||
def fake_fetch(url, verify_ssl):
|
||||
if url.startswith("https://"):
|
||||
raise TimeoutError("timed out")
|
||||
return csv_text
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_api_text", side_effect=fake_fetch) as fetch_mock,
|
||||
mock.patch.object(manager, "load_blacklist", return_value={}),
|
||||
mock.patch.object(manager, "set_state"),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
nodes = manager.fetch_candidates()
|
||||
|
||||
self.assertEqual(1, len(nodes))
|
||||
self.assertEqual(
|
||||
[mock.call(manager.API_HTTPS_URL, True), mock.call(manager.API_HTTP_URL, True)],
|
||||
fetch_mock.call_args_list,
|
||||
)
|
||||
|
||||
def test_fetch_uses_github_mirror_after_official_sources(self) -> None:
|
||||
csv_text = valid_snapshot()
|
||||
|
||||
def fake_fetch(url, verify_ssl):
|
||||
if url == manager.MIRROR_HTTPS_URL:
|
||||
return csv_text
|
||||
raise TimeoutError("blocked")
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_api_text", side_effect=fake_fetch) as fetch_mock,
|
||||
mock.patch.object(manager, "load_blacklist", return_value={}),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
nodes = manager.fetch_candidates()
|
||||
|
||||
self.assertEqual(1, len(nodes))
|
||||
self.assertEqual(
|
||||
[manager.API_HTTPS_URL, manager.API_HTTP_URL, manager.MIRROR_HTTPS_URL],
|
||||
[call.args[0] for call in fetch_mock.call_args_list],
|
||||
)
|
||||
self.assertEqual(csv_text, manager.API_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
self.assertEqual("github_pages_https", manager.get_state()["last_fetch_source"])
|
||||
|
||||
def test_http_source_does_not_replace_trusted_cache(self) -> None:
|
||||
cached_text = valid_snapshot("198.51.100.20")
|
||||
http_text = valid_snapshot("198.51.100.21")
|
||||
manager.API_CACHE_FILE.write_text(cached_text, encoding="utf-8")
|
||||
|
||||
def fake_fetch(url, verify_ssl):
|
||||
if url == manager.API_HTTP_URL:
|
||||
return http_text
|
||||
raise TimeoutError("TLS unavailable")
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_api_text", side_effect=fake_fetch),
|
||||
mock.patch.object(manager, "load_blacklist", return_value={}),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
nodes = manager.fetch_candidates()
|
||||
|
||||
self.assertEqual("198.51.100.21", nodes[0]["ip"])
|
||||
self.assertEqual(cached_text, manager.API_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
def test_fetch_falls_back_to_local_cache(self) -> None:
|
||||
cached_text = valid_snapshot("198.51.100.30")
|
||||
manager.API_CACHE_FILE.write_text(cached_text, encoding="utf-8")
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_api_text", side_effect=TimeoutError("all blocked")),
|
||||
mock.patch.object(manager, "load_blacklist", return_value={}),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
nodes = manager.fetch_candidates()
|
||||
|
||||
self.assertEqual("198.51.100.30", nodes[0]["ip"])
|
||||
self.assertEqual("local_cache", manager.get_state()["last_fetch_source"])
|
||||
|
||||
def test_bundled_snapshot_seeds_local_cache(self) -> None:
|
||||
bundled_text = valid_snapshot("198.51.100.40")
|
||||
manager.BUNDLED_SNAPSHOT_FILE.write_text(bundled_text, encoding="utf-8")
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "fetch_api_text", side_effect=TimeoutError("all blocked")),
|
||||
mock.patch.object(manager, "load_blacklist", return_value={}),
|
||||
mock.patch.object(manager, "log_to_json"),
|
||||
):
|
||||
nodes = manager.fetch_candidates()
|
||||
|
||||
self.assertEqual("198.51.100.40", nodes[0]["ip"])
|
||||
self.assertEqual(bundled_text, manager.API_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
self.assertEqual("bundled_initial", manager.get_state()["last_fetch_source"])
|
||||
|
||||
def test_snapshot_rejects_executable_openvpn_directive(self) -> None:
|
||||
unsafe_config = (
|
||||
"client\ndev tun\nproto udp\nremote 198.51.100.50 1194 udp\n"
|
||||
"script-security 2\nup /tmp/payload\n"
|
||||
"<ca>\nCA\n</ca>\n<cert>\nCERT\n</cert>\n<key>\nKEY\n</key>\n"
|
||||
)
|
||||
encoded = base64.b64encode(unsafe_config.encode("utf-8")).decode("ascii")
|
||||
csv_text = (
|
||||
"#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,OpenVPN_ConfigData_Base64\n"
|
||||
f"vpn.example,198.51.100.50,100,20,1000,Japan,JP,1,{encoded}\n"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "no valid nodes"):
|
||||
snapshot_utils.parse_and_validate_snapshot(csv_text)
|
||||
|
||||
|
||||
class ProxyServerConcurrencyTests(unittest.TestCase):
|
||||
def test_each_proxy_worker_keeps_its_accepted_socket(self) -> None:
|
||||
class Client:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.items = [(Client("first"), ("first", 1)), (Client("second"), ("second", 2))]
|
||||
|
||||
def setsockopt(self, *args):
|
||||
pass
|
||||
|
||||
def bind(self, *args):
|
||||
pass
|
||||
|
||||
def listen(self, *args):
|
||||
pass
|
||||
|
||||
def accept(self):
|
||||
if self.items:
|
||||
return self.items.pop(0)
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
class DeferredThread:
|
||||
targets = []
|
||||
|
||||
def __init__(self, target, daemon=True):
|
||||
self.target = target
|
||||
self.targets.append(target)
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
seen = []
|
||||
semaphore = mock.Mock()
|
||||
semaphore.acquire.return_value = True
|
||||
with (
|
||||
mock.patch.object(proxy_server.socket, "socket", return_value=FakeServer()),
|
||||
mock.patch.object(proxy_server.threading, "Thread", DeferredThread),
|
||||
mock.patch.object(
|
||||
proxy_server,
|
||||
"proxy_client",
|
||||
side_effect=lambda client, address: seen.append((client.name, address[0])),
|
||||
),
|
||||
mock.patch.object(proxy_server, "proxy_connection_sem", semaphore),
|
||||
):
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
proxy_server.start_proxy_server("127.0.0.1", 7928)
|
||||
for target in DeferredThread.targets:
|
||||
target()
|
||||
|
||||
self.assertEqual([("first", "first"), ("second", "second")], seen)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+14
-1
@@ -17,6 +17,8 @@ DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPN
|
||||
IP_CACHE_FILE = DATA_DIR / "ip_cache.json"
|
||||
|
||||
ip_cache_lock = threading.RLock()
|
||||
physical_interface_lock = threading.Lock()
|
||||
physical_interface_cache: tuple[str | None, float] = (None, 0.0)
|
||||
|
||||
COUNTRY_TRANSLATIONS = {
|
||||
"Japan": "日本",
|
||||
@@ -206,7 +208,7 @@ def parse_remote(config_text: str, fallback_ip: str = "") -> tuple[str, int, str
|
||||
proto = parts[3].lower()
|
||||
return remote_host, remote_port, proto
|
||||
|
||||
def get_physical_interface() -> str | None:
|
||||
def _detect_physical_interface() -> str | None:
|
||||
try:
|
||||
res = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=2)
|
||||
if res.returncode == 0:
|
||||
@@ -233,6 +235,17 @@ def get_physical_interface() -> str | None:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_physical_interface() -> str | None:
|
||||
global physical_interface_cache
|
||||
now = time.monotonic()
|
||||
with physical_interface_lock:
|
||||
cached_interface, cached_at = physical_interface_cache
|
||||
if cached_at > 0 and now - cached_at < 30:
|
||||
return cached_interface
|
||||
detected = _detect_physical_interface()
|
||||
physical_interface_cache = (detected, now)
|
||||
return detected
|
||||
|
||||
def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int:
|
||||
started = time.time()
|
||||
# Auto-detect address family based on host address
|
||||
|
||||
+690
-245
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user