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 <cursoragent@cursor.com>
This commit is contained in:
Indiana Holmes
2026-05-16 18:08:32 -07:00
parent 8f012402a6
commit 7657a37855
2 changed files with 263 additions and 50 deletions

View File

@@ -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)

View File

@@ -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("<local>", "<local>")
.strip()
bypass.replace(";", " ").replace("<local>", "<local>").strip()
) or "<local>"
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.*;<local>",
) -> 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: