From 7657a378555b5afcf2f4ab21781b7d62eb85ec80 Mon Sep 17 00:00:00 2001 From: Indiana Holmes Date: Sat, 16 May 2026 18:08:32 -0700 Subject: [PATCH] Fix Windows Server proxy: HKLM mirror, ProxySettingsPerUser fix, policy detect On Server / GPO baselines, ProxySettingsPerUser=0 makes WinINet ignore HKCU entirely. When elevated, force the flag to 1 and mirror proxy values to HKLM root + Connections blob. Detect Group Policy proxy locks and surface them so the user knows browsers will keep the policy value. Add diagnose_system_proxy() logged on every chain engage. Co-authored-by: Cursor --- proxy_chain_manager/service.py | 24 ++- proxy_chain_manager/sysproxy.py | 289 ++++++++++++++++++++++++++------ 2 files changed, 263 insertions(+), 50 deletions(-) diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index bff8cee..10a534c 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -30,7 +30,13 @@ 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, read_gost_log_tail, terminate_process from .leak_detect import is_chain_leak, leak_reason from .mac_spoof import restore_macs, spoof_all_physical -from .sysproxy import clear_system_proxy, is_system_proxy_set, set_system_proxy +from .sysproxy import ( + clear_system_proxy, + detect_policy_overrides, + diagnose_system_proxy, + is_system_proxy_set, + set_system_proxy, +) from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies from .vpn_detect import VpnStatus, detect_vpn from .win_compat import probe as _win_probe @@ -462,6 +468,17 @@ 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}"}) self._notify({"type": "phase", "phase": "running"}) + # Pre-flight: surface Group Policy locks (they will override us). + pol_before = detect_policy_overrides() + if pol_before: + self._notify({"type": "log", "text": ( + f"Group Policy proxy lock detected ({len(pol_before)} entries) — " + "these BEAT our settings. Browsers will keep the policy proxy " + "(or DIRECT) until those keys are removed." + )}) + for p in pol_before[:3]: + self._notify({"type": "log", "text": f" ! {p}"}) + set_system_proxy( self._settings.local_host, self._settings.local_port, @@ -472,10 +489,13 @@ class ChainService: "type": "log", "text": ( f"System proxy → {self._settings.listen_addr()} " - f"(WinINet root + Connections + WinHTTP) " + f"(HKCU + HKLM(adm) + Connections + WinHTTP) " f"{'OK' if applied else 'FAILED — registry write rejected'}" ), }) + # Post-flight diagnostic — every layer's actual state. + for ln in diagnose_system_proxy(): + self._notify({"type": "log", "text": f" proxy: {ln}"}) # ── Health monitor loop ─────────────────────────────────────────────── hc = int(self._settings.health_check_seconds) diff --git a/proxy_chain_manager/sysproxy.py b/proxy_chain_manager/sysproxy.py index 187fcec..a80d6fc 100644 --- a/proxy_chain_manager/sysproxy.py +++ b/proxy_chain_manager/sysproxy.py @@ -1,17 +1,20 @@ """Set / clear the Windows system-wide HTTP proxy. -Three layers get configured so the proxy actually applies system-wide: +Layers configured so the proxy actually applies system-wide: 1. ``HKCU\\…\\Internet Settings`` — simple values WinINet reads first 2. ``Internet Settings\\Connections\\DefaultConnectionSettings`` binary blob — - overrides #1 for any process that opens the per-connection structure. On - enterprise images and Windows Server boxes this stale blob is the usual - reason "chain says connected but the browser still leaks direct". -3. ``netsh winhttp set proxy`` — covers .NET, Windows Update, many - services and headless browser components that bypass WinINet. + overrides #1 for any process that opens the per-connection structure. +3. ``HKLM\\…\\Internet Settings`` mirror (Admin only) — required on Server when + ``ProxySettingsPerUser=0`` is set by Group Policy / baseline image. +4. ``netsh winhttp set proxy`` — .NET, Windows Update, services and + headless browser components that bypass WinINet. -Conflicting overrides (``AutoConfigURL`` / ``AutoDetect`` WPAD) are wiped on -engage and restored to "off" on disengage. +We also actively detect and warn about: +- ``ProxySettingsPerUser=0`` → HKCU is ignored; we fix it when Admin. +- Group Policy proxy keys → those override us; we surface the path so the + user can verify or remove them. +- ``AutoConfigURL`` / ``AutoDetect`` (WPAD) → wiped on engage. """ from __future__ import annotations @@ -27,16 +30,34 @@ _KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings" _CONN_KEY_PATH = ( r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections" ) +_HKLM_INET_PATH = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings" +_HKLM_CONN_PATH = ( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections" +) +_POLICY_PATHS = ( + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"), + (winreg.HKEY_CURRENT_USER, + r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"), + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Internet Explorer\Control Panel"), +) _SETTINGS_CHANGED = 39 _REFRESH = 37 -# Flags inside the DefaultConnectionSettings binary blob _FLAG_DIRECT = 0x01 _FLAG_MANUAL_PROXY = 0x02 _FLAG_AUTO_CONFIG = 0x04 _FLAG_AUTO_DETECT = 0x08 +def _is_admin() -> bool: + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined] + except Exception: + return False + + def _broadcast() -> None: """Tell running apps (browsers, etc.) that proxy settings changed.""" try: @@ -48,7 +69,6 @@ def _broadcast() -> None: def _bump_counter(prev: bytes | None) -> int: - """Each blob write must bump byte offset 4 for WinINet to re-read it.""" if not prev or len(prev) < 8: return 1 try: @@ -63,17 +83,7 @@ def _build_blob( auto_config_url: str = "", counter: int = 1, ) -> bytes: - """Build the ``DefaultConnectionSettings`` REG_BINARY value. - - Layout (little-endian): - u32 version (0x46) - u32 counter - u32 flags - u32 proxy_len; bytes proxy - u32 bypass_len; bytes bypass - u32 auto_config_len; bytes auto_config - bytes[32] padding (zeros) — WinINet expects trailing slack - """ + """REG_BINARY layout for DefaultConnectionSettings (little-endian).""" flags = _FLAG_DIRECT if proxy: flags |= _FLAG_MANUAL_PROXY @@ -93,41 +103,34 @@ def _build_blob( ) -def _read_blob_counter() -> int: +def _read_blob_counter(hive: int, path: str) -> int: try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE - ) as key: + with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key: val, _ = winreg.QueryValueEx(key, "DefaultConnectionSettings") return _bump_counter(val) except OSError: return 1 -def _write_conn_blob(proxy: str, bypass: str) -> None: - """Write per-connection binary so apps using the Connections key respect us.""" - counter = _read_blob_counter() +def _write_conn_blob(hive: int, path: str, proxy: str, bypass: str) -> bool: + counter = _read_blob_counter(hive, path) blob = _build_blob(proxy, bypass, "", counter) try: - # The "Connections" key may not exist on freshly imaged Server installs. - key = winreg.CreateKeyEx( - winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH, 0, winreg.KEY_SET_VALUE - ) + key = winreg.CreateKeyEx(hive, path, 0, winreg.KEY_SET_VALUE) with key: winreg.SetValueEx(key, "DefaultConnectionSettings", 0, winreg.REG_BINARY, blob) - # SavedLegacySettings mirrors the same blob on some builds; keep them in sync. winreg.SetValueEx(key, "SavedLegacySettings", 0, winreg.REG_BINARY, blob) + return True except OSError as e: - log.debug("Connections blob write failed: %s", e) + log.debug("Connections blob write failed (%s): %s", path, e) + return False -def _clear_conn_blob() -> None: - counter = _read_blob_counter() +def _clear_conn_blob(hive: int, path: str) -> None: + counter = _read_blob_counter(hive, path) blob = _build_blob("", "", "", counter) try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH, 0, winreg.KEY_SET_VALUE - ) as key: + with winreg.OpenKey(hive, path, 0, winreg.KEY_SET_VALUE) as key: winreg.SetValueEx(key, "DefaultConnectionSettings", 0, winreg.REG_BINARY, blob) winreg.SetValueEx(key, "SavedLegacySettings", 0, winreg.REG_BINARY, blob) except OSError: @@ -135,7 +138,6 @@ def _clear_conn_blob() -> None: def _wipe_autoconfig(key: winreg.HKEYType) -> None: - """Remove WPAD / PAC overrides that would silently bypass our manual proxy.""" for name in ("AutoConfigURL", "AutoDetect"): try: winreg.DeleteValue(key, name) @@ -144,12 +146,9 @@ def _wipe_autoconfig(key: winreg.HKEYType) -> None: def _set_winhttp_proxy(host: str, port: int, bypass: str) -> None: - """netsh winhttp covers .NET / services / Windows Update / some browsers.""" try: bypass_list = ( - bypass.replace(";", " ") - .replace("", "") - .strip() + bypass.replace(";", " ").replace("", "").strip() ) or "" subprocess.run( ["netsh", "winhttp", "set", "proxy", @@ -172,12 +171,178 @@ def _reset_winhttp_proxy() -> None: pass +def _read_proxy_settings_per_user() -> int | None: + """``HKLM\\…\\Internet Settings\\ProxySettingsPerUser``. + + When 0, Windows ignores HKCU proxy values and reads only HKLM. Set on many + Server images and AD policy baselines — the #1 cause of "set the proxy but + the browser still goes direct" on Server. + """ + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_QUERY_VALUE + ) as key: + val, _ = winreg.QueryValueEx(key, "ProxySettingsPerUser") + return int(val) + except OSError: + return None + + +def _fix_proxy_settings_per_user() -> bool: + """Force ``ProxySettingsPerUser=1`` (per-user proxy honored). Admin only.""" + if not _is_admin(): + return False + try: + with winreg.CreateKeyEx( + winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, "ProxySettingsPerUser", 0, winreg.REG_DWORD, 1) + log.info("HKLM\\…\\ProxySettingsPerUser → 1 (was %s)", _read_proxy_settings_per_user()) + return True + except OSError as e: + log.warning("Could not fix ProxySettingsPerUser: %s", e) + return False + + +def _set_hklm_root(proxy: str, bypass: str) -> bool: + """Mirror the per-user keys at HKLM so machine-scope WinINet reads honor us.""" + if not _is_admin(): + return False + try: + with winreg.CreateKeyEx( + winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1) + winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy) + winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass) + _wipe_autoconfig(key) + return True + except OSError as e: + log.warning("HKLM root proxy write failed: %s", e) + return False + + +def _clear_hklm_root() -> None: + if not _is_admin(): + return + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0) + _wipe_autoconfig(key) + except OSError: + pass + + +def detect_policy_overrides() -> list[str]: + """Return human-readable paths of Group Policy proxy locks that beat us. + + Anything we find here will silently override our manual proxy. We do *not* + delete policy keys (admins set those deliberately) — we just surface them. + """ + hits: list[str] = [] + proxy_value_names = ("ProxyServer", "ProxyEnable", "AutoConfigURL", "ProxyOverride") + for hive, path in _POLICY_PATHS: + try: + with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key: + for n in proxy_value_names: + try: + winreg.QueryValueEx(key, n) + hive_name = "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU" + hits.append(f"{hive_name}\\{path}\\{n}") + except OSError: + continue + except OSError: + continue + return hits + + +def _read_str(hive: int, path: str, name: str) -> str: + try: + with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key: + val, _ = winreg.QueryValueEx(key, name) + return str(val) + except OSError: + return "" + + +def _read_dword(hive: int, path: str, name: str) -> int | None: + try: + with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key: + val, _ = winreg.QueryValueEx(key, name) + return int(val) + except OSError: + return None + + +def _read_winhttp_proxy() -> str: + try: + r = subprocess.run( + ["netsh", "winhttp", "show", "proxy"], + capture_output=True, text=True, timeout=10, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + for ln in (r.stdout or "").splitlines(): + if "proxy" in ln.lower() and ":" in ln: + return ln.strip() + return (r.stdout or "").strip().splitlines()[-1] if r.stdout else "" + except Exception: + return "" + + +def diagnose_system_proxy() -> list[str]: + """Return a list of human-readable lines covering every place proxy can + be configured. Logged on each engage so "chain green but browser leaks" + is debuggable without RegEdit gymnastics. + """ + lines: list[str] = [] + per_user = _read_proxy_settings_per_user() + per_user_msg = ( + "1 (HKCU honored)" if per_user == 1 + else "0 (HKCU IGNORED — Server lock!)" if per_user == 0 + else "(unset → defaults to HKCU)" + ) + lines.append(f"ProxySettingsPerUser = {per_user_msg}") + + en_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyEnable") + ps_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyServer") + lines.append(f"HKCU Enable={en_u} Server='{ps_u}'") + + en_m = _read_dword(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyEnable") + ps_m = _read_str(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyServer") + lines.append(f"HKLM Enable={en_m} Server='{ps_m}'") + + ac_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoConfigURL") + ad_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoDetect") + if ac_u or ad_u: + lines.append(f"WPAD/PAC: AutoConfigURL='{ac_u}' AutoDetect={ad_u}") + + winhttp = _read_winhttp_proxy() + if winhttp: + lines.append(f"WinHTTP: {winhttp}") + + pol = detect_policy_overrides() + if pol: + lines.append(f"Policy overrides (these BEAT us): {len(pol)} entries") + for p in pol[:5]: + lines.append(f" ! {p}") + if len(pol) > 5: + lines.append(f" ! …and {len(pol) - 5} more") + else: + lines.append("Policy overrides: none") + + return lines + + def set_system_proxy( host: str, port: int, bypass: str = "localhost;127.*;10.*;192.168.*;", ) -> None: proxy = f"{host}:{port}" + + # HKCU root try: with winreg.OpenKey( winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE @@ -190,10 +355,35 @@ def set_system_proxy( log.exception("Failed to set system proxy (HKCU root)") return - _write_conn_blob(proxy, bypass) + _write_conn_blob(winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH, proxy, bypass) + + # If ProxySettingsPerUser=0 (Server / GPO baseline), HKCU is ignored. + # When Admin, fix the flag AND mirror to HKLM so the proxy actually applies. + per_user = _read_proxy_settings_per_user() + if per_user == 0: + log.warning( + "ProxySettingsPerUser=0 detected — Windows is ignoring per-user " + "proxy. Attempting Admin fix (HKLM mirror)." + ) + if _is_admin(): + _fix_proxy_settings_per_user() + _set_hklm_root(proxy, bypass) + _write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass) + else: + log.warning( + "Not running as Admin — cannot fix ProxySettingsPerUser. " + "Re-launch elevated for proxy to apply on this Server image." + ) + + # On Admin runs always mirror HKLM root too. Harmless when PerUser=1 and + # correct when PerUser=0 or absent. + if _is_admin(): + _set_hklm_root(proxy, bypass) + _write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass) + _set_winhttp_proxy(host, port, bypass) _broadcast() - log.info("System proxy set %s (bypass=%s) — root + Connections + WinHTTP", proxy, bypass) + log.info("System proxy set %s (bypass=%s) — HKCU + HKLM(adm) + Connections + WinHTTP", proxy, bypass) def clear_system_proxy() -> None: @@ -206,10 +396,13 @@ def clear_system_proxy() -> None: except OSError: log.exception("Failed to clear system proxy") - _clear_conn_blob() + _clear_conn_blob(winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH) + if _is_admin(): + _clear_hklm_root() + _clear_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH) _reset_winhttp_proxy() _broadcast() - log.info("System proxy cleared — root + Connections + WinHTTP") + log.info("System proxy cleared — HKCU + HKLM(adm) + Connections + WinHTTP") def is_system_proxy_set() -> bool: