diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index 86b050c..fe9c99f 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -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." ), diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index ce21810..f8d0255 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -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() diff --git a/proxy_chain_manager/sysproxy.py b/proxy_chain_manager/sysproxy.py index a80d6fc..f03b035 100644 --- a/proxy_chain_manager/sysproxy.py +++ b/proxy_chain_manager/sysproxy.py @@ -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: diff --git a/proxy_chain_manager/validator.py b/proxy_chain_manager/validator.py index 34b0223..41540fa 100644 --- a/proxy_chain_manager/validator.py +++ b/proxy_chain_manager/validator.py @@ -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" diff --git a/tests/test_proxy_god.py b/tests/test_proxy_god.py index 0571d3c..fcd0251 100644 --- a/tests/test_proxy_god.py +++ b/tests/test_proxy_god.py @@ -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.validator import ( check_chain_exit_ip, + check_https_tunnel, get_direct_ip, validate_proxies, ) @@ -206,6 +207,14 @@ class TestValidator(unittest.TestCase): self.skipTest("could not reach ipify (offline or blocked)") 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: async def run() -> str | None: return await check_chain_exit_ip(