Improve node fetching and connection failover

This commit is contained in:
Aimili
2026-08-26 20:54:24 +08:00
parent fb22c57717
commit 07983511fc
11 changed files with 1610 additions and 248 deletions
@@ -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
+1
View File
@@ -56,6 +56,7 @@ AimiliVPN_Analysis_ZH.md
# ---- 临时和测试文件 ----
scratch/
_site/
*连接配置*
test_*.py
*.bak
+14
View File
@@ -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
+102
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -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
View File
@@ -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()
+86
View File
@@ -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()
+173
View File
@@ -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"))}
+477
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff