Save-as-final-hop button, browser proxy policy + QUIC kill, HTTPS tunnel probe

Fixes 'exit proxy never used in chain' and 'browsers break when chain is green'. Chain Builder gets a Save-as-final-hop button that appends the typed exit proxy to the manual chain. On engage, Chromium browsers get pinned to our proxy via HKLM policy (ProxyMode=fixed_servers, ProxyServer, ProxyBypassList) and QuicAllowed=0 so HTTP/3 doesn't bypass HTTP proxies and stall under the kill-switch. Chain start now runs check_https_tunnel and warns clearly when proxies forward HTTP but refuse CONNECT — the real cause of green-chain-yet-blank-browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Indiana Holmes
2026-05-16 23:30:29 -07:00
parent 094e0577fb
commit f9c548872b
5 changed files with 160 additions and 4 deletions

View File

@@ -660,10 +660,34 @@ def main() -> None:
fg_color=ACCENT2, hover_color=ACCENT) fg_color=ACCENT2, hover_color=ACCENT)
test_exit_btn.pack(side="left") test_exit_btn.pack(side="left")
def _save_exit_to_chain() -> None:
"""Append the typed exit proxy to the manual chain as the final hop."""
url = _exit_url()
if not url:
_log("Exit node: paste a host:port[:user:pass] first.")
return
# If the URL is already in the chain, move it to the bottom rather than dup.
if url in manual_chain:
manual_chain.remove(url)
manual_chain.append(url)
hop_status[url] = "ok" if (
exit_test_dot.cget("text_color") == GREEN
) else "untested"
use_manual_var.set(True)
_rebuild_chain_ui()
exit_proxy_entry.delete(0, "end")
exit_user_entry.delete(0, "end")
exit_pass_entry.delete(0, "end")
exit_test_dot.configure(text_color=RED)
_log(f"Exit node added as final hop ({len(manual_chain)} total).")
_btn(exit_btn_row, "Save as final hop", _save_exit_to_chain, w=140, h=26,
fg_color=GREEN, hover_color="#00b377").pack(side="left", padx=(6, 0))
ctk.CTkLabel( ctk.CTkLabel(
exit_fix_frame, exit_fix_frame,
text=( text=(
"Appended after your chain hops. Leave empty to use last chain hop as exit.\n" "Test exit, then 'Save as final hop' appends it to YOUR CHAIN above as the last proxy.\n"
"Paste any of: host:port:user:pass • user:pass@host:port • scheme://host:port\n" "Paste any of: host:port:user:pass • user:pass@host:port • scheme://host:port\n"
"No scheme = SOCKS5. Paste auto-splits into User/Pass below." "No scheme = SOCKS5. Paste auto-splits into User/Pass below."
), ),

View File

@@ -47,7 +47,12 @@ from .sysproxy import (
is_system_proxy_set, is_system_proxy_set,
set_system_proxy, set_system_proxy,
) )
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies from .validator import (
check_chain_exit_ip,
check_https_tunnel,
get_direct_ip,
validate_proxies,
)
from .vpn_detect import VpnStatus, detect_vpn from .vpn_detect import VpnStatus, detect_vpn
from .win_compat import probe as _win_probe from .win_compat import probe as _win_probe
@@ -516,6 +521,21 @@ class ChainService:
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip}) self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"}) self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"})
# HTTPS-tunnel probe: a chain can pass HTTP IP check but refuse CONNECT.
# Without this warning, "all proxies green" yet "every browser broken"
# is a black-box failure for the user.
https_ok, https_msg = await check_https_tunnel(local_proxy, timeout)
if https_ok:
self._notify({"type": "log", "text": f"✓ HTTPS tunnel OK — {https_msg}"})
else:
self._notify({"type": "log", "text": (
"⚠ HTTPS tunnel FAILED — chain forwards plain HTTP but refuses "
"CONNECT. Browsers will time out on every HTTPS page (i.e. "
"every site). Replace the proxies that don't support CONNECT, "
"or use a SOCKS5 / paid HTTPS-capable exit. " + https_msg
)})
self._notify({"type": "phase", "phase": "running"}) self._notify({"type": "phase", "phase": "running"})
# Pre-flight: surface Group Policy locks (they will override us). # Pre-flight: surface Group Policy locks (they will override us).
pol_before = detect_policy_overrides() pol_before = detect_policy_overrides()

View File

@@ -171,6 +171,66 @@ def _reset_winhttp_proxy() -> None:
pass pass
# ── browser-policy proxy (Chrome / Edge) ─────────────────────────────────────
#
# Why this exists: when you set WebRTC policy on Chrome/Edge, the browser sees
# itself as "managed". Some managed-Chrome builds then *ignore* the system
# proxy unless an explicit ``ProxyServer`` policy is also set. They also keep
# trying HTTP/3 over QUIC (UDP/443), which bypasses HTTP proxies entirely —
# under the kill-switch those UDP packets get dropped and pages hang forever.
#
# Setting these policy values pins the browser to our chain (HTTP proxy) and
# forces it off QUIC. Cleared cleanly on disengage.
_BROWSER_POLICY_PATHS = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
r"SOFTWARE\Policies\Chromium",
)
def _set_browser_proxy_policy(host: str, port: int, bypass: str) -> int:
"""Pin Chromium-based browsers (Chrome, Edge, Chromium) to our local proxy
via Group Policy registry, and disable QUIC. Returns count of keys updated.
Requires Admin to write under HKLM\\SOFTWARE\\Policies. Silently skips when
not elevated — the user gets a warning in the service log instead.
"""
if not _is_admin():
return 0
proxy_value = f"http={host}:{port};https={host}:{port}"
bypass_value = bypass.replace(";", ",")
written = 0
for path in _BROWSER_POLICY_PATHS:
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyMode", 0, winreg.REG_SZ, "fixed_servers")
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy_value)
winreg.SetValueEx(key, "ProxyBypassList", 0, winreg.REG_SZ, bypass_value)
winreg.SetValueEx(key, "QuicAllowed", 0, winreg.REG_DWORD, 0)
written += 1
except OSError:
continue
return written
def _clear_browser_proxy_policy() -> None:
if not _is_admin():
return
for path in _BROWSER_POLICY_PATHS:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
for name in ("ProxyMode", "ProxyServer", "ProxyBypassList", "QuicAllowed"):
try:
winreg.DeleteValue(key, name)
except OSError:
pass
except OSError:
continue
def _read_proxy_settings_per_user() -> int | None: def _read_proxy_settings_per_user() -> int | None:
"""``HKLM\\\\Internet Settings\\ProxySettingsPerUser``. """``HKLM\\\\Internet Settings\\ProxySettingsPerUser``.
@@ -382,8 +442,12 @@ def set_system_proxy(
_write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass) _write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass)
_set_winhttp_proxy(host, port, bypass) _set_winhttp_proxy(host, port, bypass)
n_browser = _set_browser_proxy_policy(host, port, bypass)
_broadcast() _broadcast()
log.info("System proxy set %s (bypass=%s) — HKCU + HKLM(adm) + Connections + WinHTTP", proxy, bypass) log.info(
"System proxy set %s (bypass=%s) — HKCU + HKLM(adm) + Connections + WinHTTP + %d browser policy",
proxy, bypass, n_browser,
)
def clear_system_proxy() -> None: def clear_system_proxy() -> None:
@@ -401,8 +465,9 @@ def clear_system_proxy() -> None:
_clear_hklm_root() _clear_hklm_root()
_clear_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH) _clear_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH)
_reset_winhttp_proxy() _reset_winhttp_proxy()
_clear_browser_proxy_policy()
_broadcast() _broadcast()
log.info("System proxy cleared — HKCU + HKLM(adm) + Connections + WinHTTP") log.info("System proxy cleared — HKCU + HKLM(adm) + Connections + WinHTTP + browser policy")
def is_system_proxy_set() -> bool: def is_system_proxy_set() -> bool:

View File

@@ -237,3 +237,41 @@ async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str |
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.debug("get_direct_ip timed out after %.1fs", budget) log.debug("get_direct_ip timed out after %.1fs", budget)
return None return None
async def check_https_tunnel(
listen_proxy: str,
timeout_seconds: float = 12.0,
) -> tuple[bool, str]:
"""Return (ok, message). True means the chain successfully tunneled HTTPS.
Many free proxies pass plain HTTP but refuse HTTP ``CONNECT`` (the method
required to tunnel TLS). The chain can look healthy on an IP-check via
plain HTTP yet break every HTTPS page a browser tries to load. This probe
catches that case at start time so the user gets a clear warning instead
of a mystery "browser broken".
"""
per = max(5.0, min(20.0, float(timeout_seconds)))
t = httpx.Timeout(per, connect=min(8.0, per))
# Use a tiny, fast, very-common HTTPS endpoint.
targets = [
"https://www.google.com/generate_204",
"https://www.cloudflare.com/cdn-cgi/trace",
"https://duckduckgo.com/",
]
last_err = ""
try:
async with httpx.AsyncClient(
proxy=listen_proxy, timeout=t, verify=False, follow_redirects=True,
) as c:
for url in targets:
try:
r = await c.get(url)
if 200 <= r.status_code < 400:
return True, f"HTTPS OK via {url} ({r.status_code})"
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
return False, last_err or "all HTTPS targets failed"

View File

@@ -18,6 +18,7 @@ from proxy_chain_manager.config import (
from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries
from proxy_chain_manager.validator import ( from proxy_chain_manager.validator import (
check_chain_exit_ip, check_chain_exit_ip,
check_https_tunnel,
get_direct_ip, get_direct_ip,
validate_proxies, validate_proxies,
) )
@@ -206,6 +207,14 @@ class TestValidator(unittest.TestCase):
self.skipTest("could not reach ipify (offline or blocked)") self.skipTest("could not reach ipify (offline or blocked)")
self.assertGreater(len(ip), 4) self.assertGreater(len(ip), 4)
def test_check_https_tunnel_invalid_proxy_quick(self) -> None:
async def run() -> tuple[bool, str]:
return await check_https_tunnel("http://127.0.0.1:1", timeout_seconds=4.0)
ok, msg = asyncio.run(run())
self.assertFalse(ok)
self.assertTrue(msg)
def test_check_chain_exit_ip_invalid_proxy_quick(self) -> None: def test_check_chain_exit_ip_invalid_proxy_quick(self) -> None:
async def run() -> str | None: async def run() -> str | None:
return await check_chain_exit_ip( return await check_chain_exit_ip(