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:
@@ -660,10 +660,34 @@ def main() -> None:
|
||||
fg_color=ACCENT2, hover_color=ACCENT)
|
||||
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(
|
||||
exit_fix_frame,
|
||||
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"
|
||||
"No scheme = SOCKS5. Paste auto-splits into User/Pass below."
|
||||
),
|
||||
|
||||
@@ -47,7 +47,12 @@ from .sysproxy import (
|
||||
is_system_proxy_set,
|
||||
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 .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": "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"})
|
||||
# Pre-flight: surface Group Policy locks (they will override us).
|
||||
pol_before = detect_policy_overrides()
|
||||
|
||||
@@ -171,6 +171,66 @@ def _reset_winhttp_proxy() -> None:
|
||||
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:
|
||||
"""``HKLM\\…\\Internet Settings\\ProxySettingsPerUser``.
|
||||
|
||||
@@ -382,8 +442,12 @@ def set_system_proxy(
|
||||
_write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass)
|
||||
|
||||
_set_winhttp_proxy(host, port, bypass)
|
||||
n_browser = _set_browser_proxy_policy(host, port, bypass)
|
||||
_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:
|
||||
@@ -401,8 +465,9 @@ def clear_system_proxy() -> None:
|
||||
_clear_hklm_root()
|
||||
_clear_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH)
|
||||
_reset_winhttp_proxy()
|
||||
_clear_browser_proxy_policy()
|
||||
_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:
|
||||
|
||||
@@ -237,3 +237,41 @@ async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str |
|
||||
except asyncio.TimeoutError:
|
||||
log.debug("get_direct_ip timed out after %.1fs", budget)
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user