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)
|
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."
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user