VPN-aware leak detection, GOST stderr logging

- _is_same_network: /16 subnet comparison catches NordVPN IP rotation
  (exit_ip != real_ip exact-match missed same-VPN exits on rotated IPs)
- Leak now blacklists the entire dead chain, not just rotate
- GOST stderr/stdout -> gost.log (file, never deadlocks vs pipe)
  with 512KB rotation; last 8 lines shown in UI when GOST dies
- read_gost_log_tail helper for live debugging
- Health check uses same subnet check for consistency
- 5 new subnet tests

Made-with: Cursor
This commit is contained in:
Dr Jones
2026-04-16 01:23:00 -07:00
parent 0509d4104d
commit 8ffde94d48
3 changed files with 112 additions and 10 deletions

View File

@@ -18,7 +18,7 @@ from .config import (
)
from .fetcher import fetch_proxy_json, normalize_entries
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, terminate_process
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
from .sysproxy import clear_system_proxy, set_system_proxy
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies
@@ -30,6 +30,32 @@ Notify = Callable[[dict[str, Any]], None]
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
def _is_same_network(ip_a: str | None, ip_b: str | None, prefix_len: int = 16) -> bool:
"""True if two IPv4 addresses share the same /<prefix_len> subnet.
With NordVPN (or any VPN), the VPN provider rotates IPs so an exact-match
comparison misses leaks where the chain exits through the VPN tunnel directly.
A /16 check catches same-ISP/same-VPN exit while still allowing genuine
unrelated proxies that happen to share a /24 with the VPN exit.
Returns False if either address is None or non-IPv4.
"""
if not ip_a or not ip_b:
return False
try:
a_parts = [int(x) for x in ip_a.split(".")]
b_parts = [int(x) for x in ip_b.split(".")]
if len(a_parts) != 4 or len(b_parts) != 4:
return False
def to_int(parts: list[int]) -> int:
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (to_int(a_parts) & mask) == (to_int(b_parts) & mask)
except Exception:
return False
class ChainService:
"""Background rotating proxy chain using GOST."""
@@ -307,6 +333,11 @@ class ChainService:
self._available = [x for x in self._available if x != h]
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
tail = read_gost_log_tail(20)
if tail.strip():
for ln in tail.splitlines()[-8:]:
if ln.strip():
self._notify({"type": "log", "text": f" GOST> {ln.rstrip()}"})
return False
local_proxy = f"http://{listen}"
@@ -327,9 +358,26 @@ class ChainService:
self._proc = None
return False
if real_ip and exit_ip == real_ip:
if real_ip and _is_same_network(exit_ip, real_ip):
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Leak! Exit={exit_ip} == real IP. Rotating."})
self._notify({
"type": "log",
"text": (
f"Leak detected! Exit={exit_ip} shares network with real IP {real_ip} "
f"(same /16 subnet — chain not forwarding, likely exiting via VPN directly). Rotating."
),
})
log.warning(
"Subnet leak: exit=%s real=%s — chain proxy not forwarding. Blacklisting chain.",
exit_ip, real_ip,
)
# Blacklist the whole chain so we don't reuse broken proxies
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
terminate_process(self._proc)
self._proc = None
return False
@@ -362,9 +410,13 @@ class ChainService:
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")
if not exit_ip or (real_ip and exit_ip == real_ip):
if not exit_ip or (real_ip and _is_same_network(exit_ip, real_ip)):
reason = (
"exit IP gone" if not exit_ip
else f"exit {exit_ip} matches real network {real_ip} (VPN leak)"
)
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": "Health check failed — rotating."})
self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
break
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})