harden macOS networking port
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""Rotating proxy chain manager with a GOST backend."""
|
||||
|
||||
__version__ = "2.0.0-mac"
|
||||
__version__ = "2.0.1-mac"
|
||||
|
||||
@@ -138,6 +138,7 @@ TEXT = "#eaf2ff"
|
||||
TEXT2 = "#7a8aab"
|
||||
GLOW = "#0a87ff"
|
||||
FONT = "Segoe UI"
|
||||
IS_MAC = sys.platform == "darwin"
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
@@ -314,7 +315,7 @@ def _main_inner() -> None:
|
||||
from .firewall import is_admin as _is_admin
|
||||
except Exception:
|
||||
_is_admin = lambda: True # noqa: E731
|
||||
if svc.settings.kill_switch_enabled and not _is_admin():
|
||||
if (not IS_MAC) and svc.settings.kill_switch_enabled and not _is_admin():
|
||||
from tkinter import messagebox
|
||||
if messagebox.askyesno(
|
||||
"Kill-switch requires Admin",
|
||||
@@ -340,26 +341,26 @@ def _main_inner() -> None:
|
||||
|
||||
# Boot buttons
|
||||
ctk.CTkLabel(topbar, text="│", text_color=DIM).pack(side="left", padx=4)
|
||||
boot_lbl = ctk.CTkLabel(topbar, text="Boot:?", font=(FONT, 10), text_color=TEXT2)
|
||||
boot_lbl = ctk.CTkLabel(topbar, text=("Login:?" if IS_MAC else "Boot:?"), font=(FONT, 10), text_color=TEXT2)
|
||||
boot_lbl.pack(side="left", padx=2)
|
||||
|
||||
def _refresh_boot() -> None:
|
||||
on = task_exists()
|
||||
boot_lbl.configure(text="Boot:ON" if on else "Boot:OFF",
|
||||
boot_lbl.configure(text=("Login:ON" if on else "Login:OFF") if IS_MAC else ("Boot:ON" if on else "Boot:OFF"),
|
||||
text_color=GREEN if on else DIM)
|
||||
|
||||
def _inst_boot() -> None:
|
||||
ok, msg = install_logon_task()
|
||||
_log("Boot task installed." if ok else f"Boot install: {msg}")
|
||||
_log(("Login item installed." if ok else f"Login item install: {msg}") if IS_MAC else ("Boot task installed." if ok else f"Boot install: {msg}"))
|
||||
_refresh_boot()
|
||||
|
||||
def _rm_boot() -> None:
|
||||
ok, msg = uninstall_logon_task()
|
||||
_log("Boot task removed." if ok else f"Boot remove: {msg}")
|
||||
_log(("Login item removed." if ok else f"Login item remove: {msg}") if IS_MAC else ("Boot task removed." if ok else f"Boot remove: {msg}"))
|
||||
_refresh_boot()
|
||||
|
||||
_btn(topbar, "Boot+", _inst_boot, w=54).pack(side="left", padx=2)
|
||||
_btn(topbar, "Boot−", _rm_boot, w=54).pack(side="left", padx=2)
|
||||
_btn(topbar, "Login+" if IS_MAC else "Boot+", _inst_boot, w=64 if IS_MAC else 54).pack(side="left", padx=2)
|
||||
_btn(topbar, "Login−" if IS_MAC else "Boot−", _rm_boot, w=64 if IS_MAC else 54).pack(side="left", padx=2)
|
||||
|
||||
# Right side indicators
|
||||
rotation_lbl = ctk.CTkLabel(topbar, text="rot: 0", font=(FONT, 10), text_color=TEXT2)
|
||||
@@ -373,7 +374,7 @@ def _main_inner() -> None:
|
||||
proxy_lbl.pack(side="right", padx=(4, 4))
|
||||
ctk.CTkLabel(topbar, text="proxy:", font=(FONT, 10), text_color=TEXT2).pack(side="right")
|
||||
|
||||
fw_lbl = ctk.CTkLabel(topbar, text="FW:—", font=(FONT, 10), text_color=DIM)
|
||||
fw_lbl = ctk.CTkLabel(topbar, text="KS:N/A" if IS_MAC else "FW:—", font=(FONT, 10), text_color=DIM)
|
||||
fw_lbl.pack(side="right", padx=6)
|
||||
|
||||
sys_lbl = ctk.CTkLabel(topbar, text="SYS:—", font=(FONT, 10), text_color=DIM)
|
||||
@@ -381,13 +382,13 @@ def _main_inner() -> None:
|
||||
|
||||
admin_badge = ctk.CTkLabel(
|
||||
topbar,
|
||||
text="⚡ ADMIN" if is_admin() else "👤 USER",
|
||||
text="macOS" if IS_MAC else ("⚡ ADMIN" if is_admin() else "👤 USER"),
|
||||
font=(FONT, 10, "bold"),
|
||||
text_color=GREEN if is_admin() else YELLOW,
|
||||
text_color=GREEN if (IS_MAC or is_admin()) else YELLOW,
|
||||
)
|
||||
admin_badge.pack(side="right", padx=4)
|
||||
|
||||
if not is_admin():
|
||||
if (not IS_MAC) and not is_admin():
|
||||
def _elevate() -> None:
|
||||
# Persist UI state to disk before handing off to the elevated
|
||||
# process — otherwise unsaved edits in Chain Builder / Settings
|
||||
@@ -2340,16 +2341,18 @@ def _main_inner() -> None:
|
||||
|
||||
toggles_card = _priv_section(
|
||||
"Protections while chain is running",
|
||||
"Applied on Start, restored on Stop. macOS prompts for approval when network identity changes need it."
|
||||
if IS_MAC else
|
||||
"Applied on Start, restored on Stop. Admin required for MAC, hostname, IPv6, and WebRTC.",
|
||||
)
|
||||
mac_var = ctk.BooleanVar(value=s.mac_spoof_enabled)
|
||||
mac_rotate_var = ctk.BooleanVar(value=s.mac_rotate_on_chain_rotate)
|
||||
mac_var = ctk.BooleanVar(value=False if IS_MAC else s.mac_spoof_enabled)
|
||||
mac_rotate_var = ctk.BooleanVar(value=False if IS_MAC else s.mac_rotate_on_chain_rotate)
|
||||
host_var = ctk.BooleanVar(value=s.spoof_hostname_enabled)
|
||||
dns_flush_var = ctk.BooleanVar(value=s.flush_dns_on_rotate)
|
||||
ipv6_var = ctk.BooleanVar(value=s.disable_ipv6_while_active)
|
||||
webrtc_var = ctk.BooleanVar(value=s.harden_webrtc_enabled)
|
||||
lan_var = ctk.BooleanVar(value=s.lan_lockdown_enabled)
|
||||
telemetry_var = ctk.BooleanVar(value=s.telemetry_kill_enabled)
|
||||
webrtc_var = ctk.BooleanVar(value=False if IS_MAC else s.harden_webrtc_enabled)
|
||||
lan_var = ctk.BooleanVar(value=False if IS_MAC else s.lan_lockdown_enabled)
|
||||
telemetry_var = ctk.BooleanVar(value=False if IS_MAC else s.telemetry_kill_enabled)
|
||||
|
||||
def _priv_toggle(parent: Any, text: str, var: ctk.BooleanVar, tip: str = "") -> ctk.CTkCheckBox:
|
||||
cb = ctk.CTkCheckBox(
|
||||
@@ -2359,17 +2362,19 @@ def _main_inner() -> None:
|
||||
cb.pack(anchor="w", padx=12, pady=6)
|
||||
return cb
|
||||
|
||||
if not IS_MAC:
|
||||
_priv_toggle(
|
||||
toggles_card, "Randomize MAC addresses on physical adapters", mac_var,
|
||||
"Changes NIC MAC values while chain runs (admin required).",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card, "Re-randomize MAC on EVERY chain rotation (paranoid)", mac_rotate_var,
|
||||
"Mutates MAC every time the chain rotates — prevents long-session correlation.",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card, "Randomize MAC addresses on physical adapters", mac_var,
|
||||
"Changes NIC MAC values while chain runs (admin required).",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card, "Re-randomize MAC on EVERY chain rotation (paranoid)", mac_rotate_var,
|
||||
"Mutates MAC every time the chain rotates — prevents long-session correlation.",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card, "Spoof computer / NetBIOS hostname", host_var,
|
||||
"Temporarily renames machine identity while active (admin required).",
|
||||
toggles_card, "Spoof computer hostname" if IS_MAC else "Spoof computer / NetBIOS hostname", host_var,
|
||||
"Temporarily renames macOS ComputerName, HostName, and LocalHostName while active."
|
||||
if IS_MAC else "Temporarily renames machine identity while active (admin required).",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card, "Flush DNS cache when starting or rotating chains", dns_flush_var,
|
||||
@@ -2377,28 +2382,39 @@ def _main_inner() -> None:
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card, "Disable IPv6 on active adapters", ipv6_var,
|
||||
"Turns off IPv6 bindings while active to reduce IPv6 leak paths.",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card,
|
||||
"Harden WebRTC in Chrome / Edge (block non-proxied UDP)",
|
||||
webrtc_var,
|
||||
"Applies Windows policy to block direct WebRTC UDP bypass in Chromium browsers.",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card,
|
||||
"LAN lockdown: kill LLMNR / NetBIOS / mDNS hostname broadcasts",
|
||||
lan_var,
|
||||
"Stops your machine from advertising its hostname on the local network "
|
||||
"(admin required, reversible on stop).",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card,
|
||||
"Telemetry kill: DiagTrack, Activity History, Cortana web, ad ID",
|
||||
telemetry_var,
|
||||
"Stops Windows telemetry pipelines while chain is up. Reversible on stop "
|
||||
"(snapshot of original state taken).",
|
||||
"Turns off IPv6 with networksetup while active, then restores Automatic/Link-local state on Stop."
|
||||
if IS_MAC else "Turns off IPv6 bindings while active to reduce IPv6 leak paths.",
|
||||
)
|
||||
if IS_MAC:
|
||||
ctk.CTkLabel(
|
||||
toggles_card,
|
||||
text="Removed on macOS: global firewall kill-switch, MAC spoof, Chrome/Edge registry WebRTC policy, LAN lockdown, and Windows telemetry kill. Browser WebRTC hardening is handled by the Firefox profile in the Browser tab.",
|
||||
font=(FONT, 10),
|
||||
text_color=TEXT2,
|
||||
wraplength=860,
|
||||
justify="left",
|
||||
).pack(anchor="w", padx=12, pady=(8, 10))
|
||||
else:
|
||||
_priv_toggle(
|
||||
toggles_card,
|
||||
"Harden WebRTC in Chrome / Edge (block non-proxied UDP)",
|
||||
webrtc_var,
|
||||
"Applies Windows policy to block direct WebRTC UDP bypass in Chromium browsers.",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card,
|
||||
"LAN lockdown: kill LLMNR / NetBIOS / mDNS hostname broadcasts",
|
||||
lan_var,
|
||||
"Stops your machine from advertising its hostname on the local network "
|
||||
"(admin required, reversible on stop).",
|
||||
)
|
||||
_priv_toggle(
|
||||
toggles_card,
|
||||
"Telemetry kill: DiagTrack, Activity History, Cortana web, ad ID",
|
||||
telemetry_var,
|
||||
"Stops Windows telemetry pipelines while chain is up. Reversible on stop "
|
||||
"(snapshot of original state taken).",
|
||||
)
|
||||
|
||||
fp_card = _priv_section(
|
||||
"Device fingerprint",
|
||||
@@ -2492,7 +2508,8 @@ def _main_inner() -> None:
|
||||
# ── WebRTC leak test ─────────────────────────────────────────────────────
|
||||
rtc_card = _priv_section(
|
||||
"WebRTC leak test",
|
||||
"Scans Chrome/Edge policy, Firefox profile prefs, and live STUN server reachability.",
|
||||
"Scans Firefox profile prefs and live STUN reachability."
|
||||
if IS_MAC else "Scans Chrome/Edge policy, Firefox profile prefs, and live STUN server reachability.",
|
||||
)
|
||||
rtc_box = ctk.CTkTextbox(
|
||||
rtc_card, height=110, font=("Consolas", 10),
|
||||
@@ -2536,12 +2553,16 @@ def _main_inner() -> None:
|
||||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
|
||||
|
||||
wipe_card = _priv_section(
|
||||
"Forensic artifact wipe",
|
||||
"One-button purge of common Windows breadcrumb trails. Irreversible.",
|
||||
"Local artifact cleanup" if IS_MAC else "Forensic artifact wipe",
|
||||
"Purges app temp/log traces and clipboard on macOS. Irreversible."
|
||||
if IS_MAC else "One-button purge of common Windows breadcrumb trails. Irreversible.",
|
||||
)
|
||||
wipe_result_lbl = ctk.CTkLabel(
|
||||
wipe_card, text="Click 'Wipe now' to purge %TEMP%, Recent, Jump Lists, "
|
||||
"Prefetch (Admin), MRU lists, and the clipboard.",
|
||||
wipe_card,
|
||||
text=("Click 'Wipe now' to purge app temp/log traces and the clipboard."
|
||||
if IS_MAC else
|
||||
"Click 'Wipe now' to purge %TEMP%, Recent, Jump Lists, "
|
||||
"Prefetch (Admin), MRU lists, and the clipboard."),
|
||||
font=(FONT, 10), text_color=TEXT2, wraplength=880, justify="left",
|
||||
)
|
||||
wipe_result_lbl.pack(anchor="w", padx=12, pady=8)
|
||||
@@ -2566,6 +2587,8 @@ def _main_inner() -> None:
|
||||
|
||||
audit_card = _priv_section(
|
||||
"Leak audit (mission-critical)",
|
||||
"Probes macOS-relevant surfaces: IP, DNS, IPv6, system proxy, VPN, browser WebRTC profile, and chain exit."
|
||||
if IS_MAC else
|
||||
"Probes every leak surface: IP, DNS, IPv6, WPAD, Group Policy, "
|
||||
"ProxySettingsPerUser, LLMNR / NetBIOS / mDNS, VPN, WebRTC policy.",
|
||||
)
|
||||
@@ -2674,15 +2697,25 @@ def _main_inner() -> None:
|
||||
_fld(val_sec, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds))
|
||||
|
||||
sec_sec = _section("Security")
|
||||
ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||||
ks_row.pack(fill="x", padx=12, pady=8)
|
||||
ks_var = ctk.BooleanVar(value=s.kill_switch_enabled)
|
||||
ctk.CTkCheckBox(
|
||||
ks_row,
|
||||
text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)",
|
||||
variable=ks_var, font=(FONT, 11),
|
||||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||||
).pack(side="left")
|
||||
ks_var = ctk.BooleanVar(value=False if IS_MAC else s.kill_switch_enabled)
|
||||
if IS_MAC:
|
||||
ctk.CTkLabel(
|
||||
sec_sec,
|
||||
text="macOS mode uses loopback GOST, system proxy enforcement, hardened Firefox profiles, DNS flushing, and exit verification. The Windows netsh kill-switch is removed because there is no clean equivalent that can be safely toggled from a user app without risking a stuck network state.",
|
||||
font=(FONT, 10),
|
||||
text_color=TEXT2,
|
||||
wraplength=880,
|
||||
justify="left",
|
||||
).pack(anchor="w", padx=12, pady=8)
|
||||
else:
|
||||
ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||||
ks_row.pack(fill="x", padx=12, pady=8)
|
||||
ctk.CTkCheckBox(
|
||||
ks_row,
|
||||
text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)",
|
||||
variable=ks_var, font=(FONT, 11),
|
||||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||||
).pack(side="left")
|
||||
|
||||
# Emergency disengage — for crashes where the kill-switch lingers and
|
||||
# the operator needs internet back fast without restarting the app.
|
||||
@@ -2703,27 +2736,28 @@ def _main_inner() -> None:
|
||||
messagebox.showinfo("Kill-switch", "Firewall rules removed.")
|
||||
_log("Emergency disengage: firewall rules removed by operator.")
|
||||
|
||||
ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||||
ks_btn_row.pack(fill="x", padx=12, pady=(0, 8))
|
||||
_btn(
|
||||
ks_btn_row, "⚠ Emergency disengage firewall now",
|
||||
_emergency_disengage_now,
|
||||
w=320, h=28,
|
||||
fg_color="#7f1d1d", hover_color="#991b1b",
|
||||
).pack(side="left")
|
||||
if not IS_MAC:
|
||||
ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||||
ks_btn_row.pack(fill="x", padx=12, pady=(0, 8))
|
||||
_btn(
|
||||
ks_btn_row, "⚠ Emergency disengage firewall now",
|
||||
_emergency_disengage_now,
|
||||
w=320, h=28,
|
||||
fg_color="#7f1d1d", hover_color="#991b1b",
|
||||
).pack(side="left")
|
||||
|
||||
def _apply_point_and_shoot() -> None:
|
||||
"""Simple safe defaults: secure + low-friction launch profile."""
|
||||
use_manual_var.set(True)
|
||||
mode_var.set("auto")
|
||||
elite_var.set(False)
|
||||
ks_var.set(True)
|
||||
ks_var.set(False if IS_MAC else True)
|
||||
dns_flush_var.set(True)
|
||||
mac_var.set(False)
|
||||
mac_rotate_var.set(False)
|
||||
host_var.set(False)
|
||||
ipv6_var.set(False)
|
||||
webrtc_var.set(True)
|
||||
webrtc_var.set(False if IS_MAC else True)
|
||||
lan_var.set(False)
|
||||
telemetry_var.set(False)
|
||||
persona_var.set(PERSONA_LABELS["blend_windows_chrome"])
|
||||
@@ -2788,18 +2822,18 @@ def _main_inner() -> None:
|
||||
max_candidates=max(10, int(entries["maxc"].get().strip())),
|
||||
validation_timeout_seconds=min(120.0, max(2.0, float(entries["timeout"].get().strip()))),
|
||||
prefer_elite=bool(elite_var.get()),
|
||||
kill_switch_enabled=bool(ks_var.get()),
|
||||
kill_switch_enabled=False if IS_MAC else bool(ks_var.get()),
|
||||
proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass,
|
||||
sources=src_list or Settings().sources,
|
||||
ip_check_url=entries["check_url"].get().strip() or Settings().ip_check_url,
|
||||
mac_spoof_enabled=bool(mac_var.get()),
|
||||
mac_rotate_on_chain_rotate=bool(mac_rotate_var.get()),
|
||||
mac_spoof_enabled=False if IS_MAC else bool(mac_var.get()),
|
||||
mac_rotate_on_chain_rotate=False if IS_MAC else bool(mac_rotate_var.get()),
|
||||
spoof_hostname_enabled=bool(host_var.get()),
|
||||
flush_dns_on_rotate=bool(dns_flush_var.get()),
|
||||
disable_ipv6_while_active=bool(ipv6_var.get()),
|
||||
harden_webrtc_enabled=bool(webrtc_var.get()),
|
||||
lan_lockdown_enabled=bool(lan_var.get()),
|
||||
telemetry_kill_enabled=bool(telemetry_var.get()),
|
||||
harden_webrtc_enabled=False if IS_MAC else bool(webrtc_var.get()),
|
||||
lan_lockdown_enabled=False if IS_MAC else bool(lan_var.get()),
|
||||
telemetry_kill_enabled=False if IS_MAC else bool(telemetry_var.get()),
|
||||
firefox_path=firefox_path_var.get().strip(),
|
||||
firefox_profile_dir=firefox_profile_var.get().strip(),
|
||||
browser_clear_on_close=bool(br_clear_var.get()),
|
||||
@@ -2836,6 +2870,9 @@ def _main_inner() -> None:
|
||||
text_color=GREEN if on else DIM)
|
||||
|
||||
def _refresh_fw(engaged: bool | None = None) -> None:
|
||||
if IS_MAC:
|
||||
fw_lbl.configure(text="KS:N/A", text_color=DIM)
|
||||
return
|
||||
if engaged is None:
|
||||
engaged = fw_is_engaged()
|
||||
fw_lbl.configure(text="FW:ON" if engaged else "FW:OFF",
|
||||
@@ -3019,7 +3056,10 @@ def _main_inner() -> None:
|
||||
browser.stop(dispose=bool(br_disposable_var.get()))
|
||||
tray.stop()
|
||||
svc.stop()
|
||||
clear_system_proxy()
|
||||
try:
|
||||
clear_system_proxy()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log(f"System proxy clear failed on quit: {exc}")
|
||||
if is_admin() and fw_is_engaged():
|
||||
fw_disengage()
|
||||
root.destroy()
|
||||
@@ -3030,12 +3070,20 @@ def _main_inner() -> None:
|
||||
chain_alive = bool(svc.current_chain) or fw_is_engaged()
|
||||
if chain_alive:
|
||||
from tkinter import messagebox
|
||||
choice = messagebox.askyesnocancel(
|
||||
"Proxy God still running",
|
||||
body = (
|
||||
"The proxy chain is still active.\n\n"
|
||||
" • Yes — hide window (chain keeps running)\n"
|
||||
" • No — fully quit (stop chain + clear system proxy)\n"
|
||||
" • Cancel — keep window open"
|
||||
if IS_MAC else
|
||||
"The proxy chain and/or kill-switch are still active.\n\n"
|
||||
" • Yes — minimize to tray (chain keeps running)\n"
|
||||
" • No — fully quit (stop chain + remove firewall rules)\n"
|
||||
" • Cancel — keep window open",
|
||||
" • Cancel — keep window open"
|
||||
)
|
||||
choice = messagebox.askyesnocancel(
|
||||
"Proxy God still running",
|
||||
body,
|
||||
)
|
||||
if choice is None:
|
||||
return
|
||||
@@ -3057,13 +3105,13 @@ def _main_inner() -> None:
|
||||
_refresh_fingerprint()
|
||||
_log("─" * 60)
|
||||
_log("Proxy God v2 — ready.")
|
||||
_log(f"Admin: {'YES — kill-switch + privacy hardening available' if is_admin() else 'NO — run as Admin for full privacy tools'}")
|
||||
_log("macOS networking: system proxy uses networksetup and will prompt for approval if required." if IS_MAC else f"Admin: {'YES — kill-switch + privacy hardening available' if is_admin() else 'NO — run as Admin for full privacy tools'}")
|
||||
_log(f"Log file: {LOG_PATH}")
|
||||
_log("Press START to fetch, validate and chain proxies.")
|
||||
_log("Chain Builder → build your chain, Test entire chain, then Start.")
|
||||
_log("Browser tab → launch hardened Firefox profile that follows your chain.")
|
||||
_log("Signup Prep → open signup pages with autofill (Google / Proton / HydraProxy / custom).")
|
||||
_log("Privacy tab → MAC, hostname, IPv6, WebRTC, fingerprint audit, DNS checks.")
|
||||
_log("Privacy tab → hostname, IPv6, DNS/WebRTC checks, fingerprint audit." if IS_MAC else "Privacy tab → MAC, hostname, IPv6, WebRTC, fingerprint audit, DNS checks.")
|
||||
_log("Works with or without VPN — leak detection adapts automatically.")
|
||||
_log("─" * 60)
|
||||
_pump()
|
||||
|
||||
@@ -24,6 +24,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -143,6 +144,13 @@ def _clear_mru_key(path: str, rep: WipeReport) -> None:
|
||||
|
||||
|
||||
def _clear_clipboard(rep: WipeReport) -> None:
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
subprocess.run(["pbcopy"], input="", text=True, timeout=5)
|
||||
rep.clipboard_cleared = True
|
||||
except Exception as e:
|
||||
rep.errors.append(f"clipboard: {e}")
|
||||
return
|
||||
try:
|
||||
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
|
||||
if user32.OpenClipboard(None):
|
||||
@@ -157,6 +165,27 @@ def _clear_clipboard(rep: WipeReport) -> None:
|
||||
|
||||
def wipe_artifacts(include_prefetch: bool = True) -> WipeReport:
|
||||
rep = WipeReport()
|
||||
if sys.platform == "darwin":
|
||||
import tempfile
|
||||
temp = Path(tempfile.gettempdir())
|
||||
_purge_dir(temp, rep, keep_root=True)
|
||||
for path in (
|
||||
Path.home() / "Library" / "Caches" / "ProxyChainManager",
|
||||
Path.home() / "Library" / "Logs" / "ProxyGod.out.log",
|
||||
Path.home() / "Library" / "Logs" / "ProxyGod.err.log",
|
||||
):
|
||||
try:
|
||||
if path.is_dir():
|
||||
_purge_dir(path, rep, keep_root=False)
|
||||
elif path.is_file():
|
||||
sz = path.stat().st_size
|
||||
path.unlink(missing_ok=True)
|
||||
rep.files_deleted += 1
|
||||
rep.bytes_freed += sz
|
||||
except OSError as exc:
|
||||
rep.errors.append(f"{path.name}: {exc}")
|
||||
_clear_clipboard(rep)
|
||||
return rep
|
||||
appdata = Path(os.environ.get("APPDATA", "")) if os.environ.get("APPDATA") else None
|
||||
temp = Path(os.environ.get("TEMP", "")) if os.environ.get("TEMP") else None
|
||||
sysroot = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
||||
|
||||
@@ -303,6 +303,19 @@ def migrate(raw: dict) -> dict:
|
||||
def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
|
||||
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
|
||||
changed = False
|
||||
if sys.platform != "win32":
|
||||
unsupported = (
|
||||
"kill_switch_enabled",
|
||||
"mac_spoof_enabled",
|
||||
"mac_rotate_on_chain_rotate",
|
||||
"harden_webrtc_enabled",
|
||||
"lan_lockdown_enabled",
|
||||
"telemetry_kill_enabled",
|
||||
)
|
||||
for name in unsupported:
|
||||
if bool(getattr(s, name, False)):
|
||||
setattr(s, name, False)
|
||||
changed = True
|
||||
try:
|
||||
cl = int(s.chain_length)
|
||||
if not (1 <= cl <= 8):
|
||||
|
||||
@@ -7,6 +7,7 @@ import random
|
||||
import re
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -14,6 +15,9 @@ from .firewall import is_admin
|
||||
from .mac_spoof import list_nics
|
||||
from .win_compat import probe as _win_probe
|
||||
|
||||
if sys.platform == "darwin":
|
||||
from .macos_privileged import run_shell, run_shell_as_admin, shell_quote
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome"
|
||||
@@ -26,6 +30,43 @@ _WEBRTC_VALUE_STR = "WebRtcIPHandling"
|
||||
_WEBRTC_DISABLE_STR = "disable_non_proxied_udp"
|
||||
|
||||
|
||||
def _mac_network_services() -> list[str]:
|
||||
if sys.platform != "darwin":
|
||||
return []
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["networksetup", "-listallnetworkservices"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
services: list[str] = []
|
||||
for line in (r.stdout or "").splitlines():
|
||||
name = line.strip()
|
||||
if not name or name.startswith("An asterisk"):
|
||||
continue
|
||||
services.append(name.lstrip("*").strip())
|
||||
return services
|
||||
|
||||
|
||||
def _mac_ipv6_state(service: str) -> str:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["networksetup", "-getinfo", service],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
)
|
||||
for line in (r.stdout or "").splitlines():
|
||||
if line.strip().startswith("IPv6:"):
|
||||
return line.split(":", 1)[1].strip() or "Automatic"
|
||||
except Exception:
|
||||
pass
|
||||
return "Automatic"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FingerprintAudit:
|
||||
lines: list[str] = field(default_factory=list)
|
||||
@@ -51,6 +92,12 @@ def _run_ps(script: str, timeout: float = 15.0) -> str:
|
||||
|
||||
|
||||
def get_computer_name() -> str:
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
r = subprocess.run(["scutil", "--get", "ComputerName"], capture_output=True, text=True, timeout=5)
|
||||
return (r.stdout or "").strip() or platform.node() or ""
|
||||
except Exception:
|
||||
return platform.node() or ""
|
||||
try:
|
||||
import os
|
||||
return os.environ.get("COMPUTERNAME", "") or platform.node() or ""
|
||||
@@ -77,6 +124,19 @@ def random_hostname(prefix: str = "PC") -> str:
|
||||
|
||||
def set_computer_name(name: str) -> tuple[bool, str]:
|
||||
"""Set NetBIOS / computer name (Admin). Reboot may be required for all apps."""
|
||||
if sys.platform == "darwin":
|
||||
clean = re.sub(r"[^A-Za-z0-9\-]", "", name)[:63]
|
||||
local = clean[:63].strip("-") or random_hostname("MAC")
|
||||
script = "\n".join([
|
||||
f"scutil --set ComputerName {shell_quote(clean)}",
|
||||
f"scutil --set HostName {shell_quote(clean)}",
|
||||
f"scutil --set LocalHostName {shell_quote(local)}",
|
||||
"dscacheutil -flushcache || true",
|
||||
])
|
||||
code, _, err = run_shell_as_admin(script)
|
||||
if code == 0:
|
||||
return True, f"macOS host identity set to {clean}."
|
||||
return False, f"macOS hostname change failed: {err.strip() or 'permission denied'}"
|
||||
if not is_admin():
|
||||
return False, "Administrator required to change computer name."
|
||||
name = re.sub(r"[^A-Za-z0-9\-]", "", name)[:15]
|
||||
@@ -110,6 +170,24 @@ def set_computer_name(name: str) -> tuple[bool, str]:
|
||||
|
||||
def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
|
||||
"""Disable IPv6 binding on up physical adapters. Returns (adapter names, log lines)."""
|
||||
if sys.platform == "darwin":
|
||||
services = _mac_network_services()
|
||||
if not services:
|
||||
return [], ["IPv6 disable skipped: no macOS network services found."]
|
||||
states: list[str] = []
|
||||
commands = ["set -e"]
|
||||
for service in services:
|
||||
state = _mac_ipv6_state(service)
|
||||
if state.lower() == "off":
|
||||
continue
|
||||
states.append(f"{service}|{state}")
|
||||
commands.append(f"networksetup -setv6off {shell_quote(service)}")
|
||||
if not states:
|
||||
return [], ["IPv6 already off on all detected macOS network services."]
|
||||
code, _, err = run_shell_as_admin("\n".join(commands))
|
||||
if code != 0:
|
||||
return [], [f"IPv6 disable failed: {err.strip() or 'permission denied'}"]
|
||||
return states, [f"IPv6 disabled on {len(states)} macOS network service(s)."]
|
||||
if not is_admin():
|
||||
return [], ["IPv6 disable skipped (not Admin)."]
|
||||
if not _win_probe().has_net_cmdlets:
|
||||
@@ -144,6 +222,23 @@ def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
|
||||
|
||||
|
||||
def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]:
|
||||
if sys.platform == "darwin":
|
||||
if not adapters:
|
||||
return []
|
||||
commands = ["set -e"]
|
||||
for item in adapters:
|
||||
service, _, state = item.partition("|")
|
||||
state_l = (state or "Automatic").lower()
|
||||
if "link-local" in state_l:
|
||||
commands.append(f"networksetup -setv6linklocal {shell_quote(service)}")
|
||||
elif "manual" in state_l:
|
||||
commands.append(f"networksetup -setv6automatic {shell_quote(service)}")
|
||||
else:
|
||||
commands.append(f"networksetup -setv6automatic {shell_quote(service)}")
|
||||
code, _, err = run_shell_as_admin("\n".join(commands))
|
||||
if code != 0:
|
||||
return [f"IPv6 restore failed: {err.strip() or 'permission denied'}"]
|
||||
return [f"IPv6 restored on {len(adapters)} macOS network service(s)."]
|
||||
if not is_admin() or not adapters:
|
||||
return []
|
||||
if not _win_probe().has_net_cmdlets:
|
||||
@@ -171,6 +266,8 @@ def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]:
|
||||
the modern REG_SZ key (WebRtcIPHandling=disable_non_proxied_udp) so that
|
||||
all Chrome/Edge versions are covered.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return False, "Chrome/Edge WebRTC policy is not applied on macOS; use the hardened Firefox profile controls."
|
||||
if not is_admin():
|
||||
return False, "Administrator required for browser WebRTC policy."
|
||||
paths = [_WEBRTC_CHROME, _WEBRTC_EDGE]
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -60,6 +61,22 @@ def _check_ipv6_active() -> tuple[bool, str]:
|
||||
PowerShell 3.0+ path; falls back to ipconfig parsing (locale-tolerant
|
||||
enough — looks for hex colons).
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
r = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=8)
|
||||
active: list[str] = []
|
||||
current = ""
|
||||
for line in (r.stdout or "").splitlines():
|
||||
if line and not line.startswith("\t") and ":" in line:
|
||||
current = line.split(":", 1)[0]
|
||||
elif "\tinet6 " in line and current and not line.strip().startswith("inet6 ::1"):
|
||||
active.append(current)
|
||||
uniq = sorted(set(active))
|
||||
if uniq:
|
||||
return True, ", ".join(uniq[:3]) + (f" +{len(uniq)-3}" if len(uniq) > 3 else "")
|
||||
return False, "no non-loopback IPv6 addresses"
|
||||
except Exception as e:
|
||||
return False, f"unknown ({e})"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||
@@ -92,6 +109,8 @@ def _check_ipv6_active() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_wpad() -> tuple[bool, str]:
|
||||
if sys.platform != "win32":
|
||||
return True, "not applicable on macOS"
|
||||
path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||
autoconf = ""
|
||||
autodet = 0
|
||||
@@ -121,6 +140,8 @@ def _check_wpad() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_per_user_flag() -> tuple[bool, str]:
|
||||
if sys.platform != "win32":
|
||||
return True, "not applicable on macOS"
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
@@ -137,6 +158,8 @@ def _check_per_user_flag() -> tuple[bool, str]:
|
||||
|
||||
def _check_webrtc_policy() -> tuple[bool, str]:
|
||||
"""OK means the policy IS set (browsers won't leak non-proxied UDP)."""
|
||||
if sys.platform != "win32":
|
||||
return True, "managed by hardened Firefox profile settings"
|
||||
paths = (
|
||||
r"SOFTWARE\Policies\Google\Chrome",
|
||||
r"SOFTWARE\Policies\Microsoft\Edge",
|
||||
@@ -273,7 +296,10 @@ async def run_audit(
|
||||
|
||||
# LAN-scope broadcasts
|
||||
lan = lan_status()
|
||||
lan_clean = "OFF" in lan["llmnr"] and "OFF" in lan["mdns"] and "NICs with NetBIOS disabled" in lan["netbios"]
|
||||
lan_clean = (
|
||||
sys.platform != "win32"
|
||||
or ("OFF" in lan["llmnr"] and "OFF" in lan["mdns"] and "NICs with NetBIOS disabled" in lan["netbios"])
|
||||
)
|
||||
rep.add(
|
||||
"LAN broadcast (LLMNR/NetBIOS/mDNS)",
|
||||
lan_clean,
|
||||
@@ -292,14 +318,14 @@ async def run_audit(
|
||||
|
||||
# WebRTC policy
|
||||
rtc_ok, rtc_msg = _check_webrtc_policy()
|
||||
rep.add("Browser WebRTC policy", rtc_ok, rtc_msg)
|
||||
rep.add("Browser WebRTC protection", rtc_ok, rtc_msg)
|
||||
|
||||
# Admin status (a lot of fixes require it)
|
||||
rep.add(
|
||||
"Administrator privileges",
|
||||
is_admin(),
|
||||
"Yes" if is_admin() else "No",
|
||||
"MAC/IPv6/LAN/HKLM fixes need elevation" if not is_admin() else "",
|
||||
"Privilege model",
|
||||
True if sys.platform == "darwin" else is_admin(),
|
||||
"macOS prompts when network changes need approval" if sys.platform == "darwin" else ("Yes" if is_admin() else "No"),
|
||||
"" if sys.platform == "darwin" or is_admin() else "MAC/IPv6/LAN/HKLM fixes need elevation",
|
||||
)
|
||||
|
||||
return rep
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .firewall import is_admin
|
||||
@@ -41,6 +42,26 @@ def list_nics() -> list[NicMac]:
|
||||
|
||||
Falls back to ``wmic nic`` for hosts without PowerShell 3.0 (Server 2008 R2).
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
code, out, _ = _run(["ifconfig"], timeout=10)
|
||||
if code != 0:
|
||||
return []
|
||||
nics: list[NicMac] = []
|
||||
current = ""
|
||||
is_up = False
|
||||
mac = ""
|
||||
for line in out.splitlines():
|
||||
if line and not line.startswith("\t") and ":" in line:
|
||||
if current and is_up and mac:
|
||||
nics.append(NicMac(name=current, mac=mac, description=current))
|
||||
current = line.split(":", 1)[0].strip()
|
||||
is_up = "UP" in line
|
||||
mac = ""
|
||||
elif "\tether " in line:
|
||||
mac = line.strip().split()[1].replace("-", ":")
|
||||
if current and is_up and mac:
|
||||
nics.append(NicMac(name=current, mac=mac, description=current))
|
||||
return nics
|
||||
compat = _win_probe()
|
||||
if not compat.has_net_cmdlets:
|
||||
return _list_nics_wmic()
|
||||
|
||||
45
proxy_chain_manager/macos_privileged.py
Normal file
45
proxy_chain_manager/macos_privileged.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
|
||||
def shell_quote(value: str) -> str:
|
||||
return shlex.quote(str(value))
|
||||
|
||||
|
||||
def run_shell(script: str, timeout: float = 120.0) -> tuple[int, str, str]:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/bin/bash", "-lc", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return r.returncode, r.stdout or "", r.stderr or ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 1, "", str(exc)
|
||||
|
||||
|
||||
def run_shell_as_admin(script: str, timeout: float = 180.0) -> tuple[int, str, str]:
|
||||
"""Run a shell script through macOS' standard admin prompt.
|
||||
|
||||
This avoids silent failures for commands like ``networksetup`` and
|
||||
``scutil --set`` when the user is not currently root.
|
||||
"""
|
||||
apple_script = (
|
||||
"do shell script "
|
||||
+ json.dumps(script)
|
||||
+ " with administrator privileges"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["osascript", "-e", apple_script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return r.returncode, r.stdout or "", r.stderr or ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 1, "", str(exc)
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import winreg
|
||||
@@ -164,6 +165,12 @@ def _restart_dnscache() -> None:
|
||||
|
||||
def lan_status() -> dict[str, str]:
|
||||
"""Human-readable current state. Returned even when not Admin."""
|
||||
if sys.platform == "darwin":
|
||||
return {
|
||||
"netbios": "not applicable on macOS",
|
||||
"llmnr": "not applicable on macOS",
|
||||
"mdns": "managed by mDNSResponder",
|
||||
}
|
||||
nb = _list_netbios_via_wmic()
|
||||
if nb:
|
||||
disabled = sum(1 for v in nb.values() if v == 2)
|
||||
@@ -193,6 +200,8 @@ def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
|
||||
snapshot is None when the operation was refused (not Admin, etc.).
|
||||
"""
|
||||
logs: list[str] = []
|
||||
if sys.platform == "darwin":
|
||||
return None, ["LAN lockdown is not applied on macOS; mDNSResponder is a core system service."]
|
||||
if not is_admin():
|
||||
return None, ["LAN privacy lockdown skipped — needs Administrator."]
|
||||
|
||||
@@ -227,6 +236,8 @@ def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
|
||||
|
||||
def restore_lan(snap: LanSnapshot | None) -> list[str]:
|
||||
"""Roll back to the snapshot captured by engage_lan_lockdown."""
|
||||
if sys.platform == "darwin":
|
||||
return []
|
||||
if snap is None or not is_admin():
|
||||
return []
|
||||
logs: list[str] = []
|
||||
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -204,8 +205,11 @@ class ChainService:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _teardown_network(self) -> None:
|
||||
clear_system_proxy()
|
||||
self._notify({"type": "log", "text": "System proxy cleared."})
|
||||
try:
|
||||
clear_system_proxy()
|
||||
self._notify({"type": "log", "text": "System proxy cleared."})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._notify({"type": "log", "text": f"System proxy clear failed: {exc}"})
|
||||
self._restore_privacy()
|
||||
with self._settings_lock:
|
||||
ks_enabled = self._settings.kill_switch_enabled
|
||||
@@ -216,14 +220,15 @@ class ChainService:
|
||||
|
||||
def _apply_privacy(self) -> None:
|
||||
s = self._settings
|
||||
can_prompt_admin = sys.platform == "darwin"
|
||||
if s.mac_spoof_enabled and is_admin():
|
||||
self._mac_originals, logs = spoof_all_physical(self._mac_originals or None)
|
||||
for ln in logs:
|
||||
self._notify({"type": "log", "text": f"MAC: {ln}"})
|
||||
elif s.mac_spoof_enabled:
|
||||
self._notify({"type": "log", "text": "MAC spoof enabled but not Admin — skipped."})
|
||||
self._notify({"type": "log", "text": "MAC spoof unavailable on macOS — skipped." if can_prompt_admin else "MAC spoof enabled but not Admin — skipped."})
|
||||
|
||||
if s.spoof_hostname_enabled and is_admin():
|
||||
if s.spoof_hostname_enabled and (is_admin() or can_prompt_admin):
|
||||
self._hostname_original = get_computer_name()
|
||||
new_name = random_hostname()
|
||||
ok, msg = set_computer_name(new_name)
|
||||
@@ -231,7 +236,7 @@ class ChainService:
|
||||
elif s.spoof_hostname_enabled:
|
||||
self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."})
|
||||
|
||||
if s.disable_ipv6_while_active and is_admin():
|
||||
if s.disable_ipv6_while_active and (is_admin() or can_prompt_admin):
|
||||
self._ipv6_adapters, logs = disable_ipv6_on_adapters()
|
||||
for ln in logs:
|
||||
self._notify({"type": "log", "text": ln})
|
||||
@@ -243,7 +248,7 @@ class ChainService:
|
||||
self._webrtc_was_applied = ok
|
||||
self._notify({"type": "log", "text": msg})
|
||||
elif s.harden_webrtc_enabled:
|
||||
self._notify({"type": "log", "text": "WebRTC hardening enabled but not Admin — skipped."})
|
||||
self._notify({"type": "log", "text": "Chrome/Edge WebRTC policy is Windows-only; use Browser tab Firefox WebRTC hardening." if can_prompt_admin else "WebRTC hardening enabled but not Admin — skipped."})
|
||||
|
||||
if s.lan_lockdown_enabled and is_admin():
|
||||
snap, logs = engage_lan_lockdown()
|
||||
@@ -251,7 +256,7 @@ class ChainService:
|
||||
for ln in logs:
|
||||
self._notify({"type": "log", "text": f"LAN: {ln}"})
|
||||
elif s.lan_lockdown_enabled:
|
||||
self._notify({"type": "log", "text": "LAN lockdown enabled but not Admin — skipped."})
|
||||
self._notify({"type": "log", "text": "LAN lockdown is Windows-only and disabled on macOS." if can_prompt_admin else "LAN lockdown enabled but not Admin — skipped."})
|
||||
|
||||
if s.telemetry_kill_enabled and is_admin():
|
||||
tsnap, tlogs = engage_telemetry_kill()
|
||||
@@ -261,14 +266,14 @@ class ChainService:
|
||||
if len(tlogs) > 8:
|
||||
self._notify({"type": "log", "text": f"Telemetry: … +{len(tlogs) - 8} more changes."})
|
||||
elif s.telemetry_kill_enabled:
|
||||
self._notify({"type": "log", "text": "Telemetry kill enabled but not Admin — skipped."})
|
||||
self._notify({"type": "log", "text": "Telemetry kill is Windows-only and disabled on macOS." if can_prompt_admin else "Telemetry kill enabled but not Admin — skipped."})
|
||||
|
||||
def _restore_privacy(self) -> None:
|
||||
if self._mac_originals:
|
||||
for ln in restore_macs(self._mac_originals):
|
||||
self._notify({"type": "log", "text": f"MAC restore: {ln}"})
|
||||
self._mac_originals.clear()
|
||||
if self._hostname_original and is_admin():
|
||||
if self._hostname_original and (is_admin() or sys.platform == "darwin"):
|
||||
ok, msg = set_computer_name(self._hostname_original)
|
||||
self._notify({"type": "log", "text": f"Hostname restore: {msg}"})
|
||||
self._hostname_original = None
|
||||
@@ -657,11 +662,17 @@ class ChainService:
|
||||
for p in pol_before[:3]:
|
||||
self._notify({"type": "log", "text": f" ! {p}"})
|
||||
|
||||
set_system_proxy(
|
||||
self._settings.local_host,
|
||||
self._settings.local_port,
|
||||
self._settings.proxy_bypass,
|
||||
)
|
||||
try:
|
||||
set_system_proxy(
|
||||
self._settings.local_host,
|
||||
self._settings.local_port,
|
||||
self._settings.proxy_bypass,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._notify({"type": "log", "text": f"System proxy apply failed: {exc}"})
|
||||
terminate_process(self._proc)
|
||||
self._proc = None
|
||||
return False
|
||||
applied = is_system_proxy_set()
|
||||
self._notify({
|
||||
"type": "log",
|
||||
|
||||
@@ -27,6 +27,9 @@ import winreg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if sys.platform == "darwin":
|
||||
from .macos_privileged import run_shell, run_shell_as_admin, shell_quote
|
||||
|
||||
_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||
_CONN_KEY_PATH = (
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"
|
||||
@@ -73,11 +76,22 @@ def _mac_network_services() -> list[str]:
|
||||
return services
|
||||
|
||||
|
||||
def _mac_run_networksetup(args: list[str]) -> None:
|
||||
def _mac_run_networksetup(args: list[str]) -> bool:
|
||||
try:
|
||||
subprocess.run(["networksetup", *args], capture_output=True, text=True, timeout=20)
|
||||
r = subprocess.run(["networksetup", *args], capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
log.debug("networksetup failed (%s): %s", args, (r.stderr or r.stdout or "").strip())
|
||||
return r.returncode == 0
|
||||
except Exception as exc:
|
||||
log.debug("networksetup failed (%s): %s", args, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _mac_networksetup_script(commands: list[list[str]]) -> str:
|
||||
lines = ["set -e"]
|
||||
for args in commands:
|
||||
lines.append("networksetup " + " ".join(shell_quote(a) for a in args))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None:
|
||||
@@ -86,21 +100,44 @@ def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None:
|
||||
for h in bypass.replace(";", ",").split(",")
|
||||
if h.strip() and h.strip() != "<local>"
|
||||
]
|
||||
for service in _mac_network_services():
|
||||
_mac_run_networksetup(["-setwebproxy", service, host, str(port)])
|
||||
_mac_run_networksetup(["-setsecurewebproxy", service, host, str(port)])
|
||||
_mac_run_networksetup(["-setwebproxystate", service, "on"])
|
||||
_mac_run_networksetup(["-setsecurewebproxystate", service, "on"])
|
||||
services = _mac_network_services()
|
||||
commands: list[list[str]] = []
|
||||
for service in services:
|
||||
commands.extend([
|
||||
["-setwebproxy", service, host, str(port)],
|
||||
["-setsecurewebproxy", service, host, str(port)],
|
||||
["-setwebproxystate", service, "on"],
|
||||
["-setsecurewebproxystate", service, "on"],
|
||||
])
|
||||
if bypass_hosts:
|
||||
_mac_run_networksetup(["-setproxybypassdomains", service, *bypass_hosts])
|
||||
log.info("macOS system proxy set to %s:%s for %d service(s)", host, port, len(_mac_network_services()))
|
||||
commands.append(["-setproxybypassdomains", service, *bypass_hosts])
|
||||
script = _mac_networksetup_script(commands)
|
||||
code, _, err = run_shell(script)
|
||||
if code != 0:
|
||||
log.info("networksetup needs administrator approval; requesting macOS credentials.")
|
||||
code, _, err = run_shell_as_admin(script)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"macOS system proxy apply failed: {err.strip() or 'networksetup failed'}")
|
||||
log.info("macOS system proxy set to %s:%s for %d service(s)", host, port, len(services))
|
||||
|
||||
|
||||
def _mac_clear_system_proxy() -> None:
|
||||
for service in _mac_network_services():
|
||||
_mac_run_networksetup(["-setwebproxystate", service, "off"])
|
||||
_mac_run_networksetup(["-setsecurewebproxystate", service, "off"])
|
||||
log.info("macOS system proxy cleared for %d service(s)", len(_mac_network_services()))
|
||||
services = _mac_network_services()
|
||||
commands = []
|
||||
for service in services:
|
||||
commands.extend([
|
||||
["-setwebproxystate", service, "off"],
|
||||
["-setsecurewebproxystate", service, "off"],
|
||||
])
|
||||
if not commands:
|
||||
return
|
||||
script = _mac_networksetup_script(commands)
|
||||
code, _, err = run_shell(script)
|
||||
if code != 0:
|
||||
code, _, err = run_shell_as_admin(script)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"macOS system proxy clear failed: {err.strip() or 'networksetup failed'}")
|
||||
log.info("macOS system proxy cleared for %d service(s)", len(services))
|
||||
|
||||
|
||||
def _mac_system_proxy_set() -> bool:
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -175,6 +176,8 @@ def _delete_reg_value(hive: int, path: str, name: str) -> None:
|
||||
|
||||
def telemetry_status() -> dict[str, str]:
|
||||
"""Read-only snapshot for the audit panel."""
|
||||
if sys.platform == "darwin":
|
||||
return {"macOS": "not applicable"}
|
||||
out: dict[str, str] = {}
|
||||
for svc in _SERVICES:
|
||||
out[f"svc.{svc}"] = _service_start_type(svc)
|
||||
@@ -199,6 +202,8 @@ def telemetry_status() -> dict[str, str]:
|
||||
|
||||
def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]:
|
||||
logs: list[str] = []
|
||||
if sys.platform == "darwin":
|
||||
return None, ["Telemetry kill is Windows-only; no macOS telemetry mutation applied."]
|
||||
if not is_admin():
|
||||
return None, ["Telemetry kill skipped — needs Administrator."]
|
||||
snap = TelemetrySnapshot()
|
||||
@@ -240,6 +245,8 @@ def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]:
|
||||
|
||||
|
||||
def restore_telemetry(snap: TelemetrySnapshot | None) -> list[str]:
|
||||
if sys.platform == "darwin":
|
||||
return []
|
||||
if snap is None or not is_admin():
|
||||
return []
|
||||
logs: list[str] = []
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -78,6 +79,8 @@ def check_chromium_webrtc_policy() -> tuple[bool, str]:
|
||||
Accepts either the legacy DWORD DefaultWebRtcIpHandlingPolicy==3
|
||||
OR the modern REG_SZ WebRtcIPHandling=="disable_non_proxied_udp".
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return True, "Chrome/Edge registry policy not applicable on macOS"
|
||||
found: list[str] = []
|
||||
missing: list[str] = []
|
||||
for path in _CHROMIUM_POLICY_PATHS:
|
||||
@@ -207,7 +210,8 @@ def run_webrtc_check(profile_dir: Path | None = None) -> WebRtcCheckResult:
|
||||
policy_ok, policy_detail = check_chromium_webrtc_policy()
|
||||
res.chrome_edge_policy_set = policy_ok
|
||||
res.chrome_edge_policy_detail = policy_detail
|
||||
res.add("Chrome/Edge WebRTC policy (HKLM)", policy_ok, policy_detail)
|
||||
if sys.platform == "win32":
|
||||
res.add("Chrome/Edge WebRTC policy (HKLM)", policy_ok, policy_detail)
|
||||
|
||||
# 2. Firefox profile prefs
|
||||
if profile_dir is not None:
|
||||
@@ -223,8 +227,14 @@ def run_webrtc_check(profile_dir: Path | None = None) -> WebRtcCheckResult:
|
||||
stun_reachable, stun_detail = check_stun_reachability()
|
||||
res.stun_reachable = stun_reachable
|
||||
res.stun_detail = stun_detail
|
||||
# STUN reachable = risk; NOT reachable = good (firewall is blocking it)
|
||||
res.add("STUN UDP reachability", not stun_reachable, stun_detail)
|
||||
# STUN reachable is only a leak risk when the active browser profile has
|
||||
# not disabled WebRTC. On macOS the clean port is Firefox-profile based,
|
||||
# not OS registry based.
|
||||
stun_ok = (not stun_reachable) or (sys.platform != "win32" and res.firefox_prefs_ok)
|
||||
detail = stun_detail
|
||||
if stun_reachable and stun_ok:
|
||||
detail += " — browser profile disables WebRTC, so UDP reachability is informational"
|
||||
res.add("STUN UDP reachability", stun_ok, detail)
|
||||
|
||||
# Overall verdict
|
||||
res.any_leak_risk = bool(res.issues)
|
||||
|
||||
@@ -3,10 +3,53 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
|
||||
from .paths import app_data_dir
|
||||
|
||||
TASK_NAME = "ProxyChainManagerAutoStart"
|
||||
LAUNCH_AGENT_ID = "local.proxy-god.mac"
|
||||
|
||||
|
||||
def _launch_agent_path() -> Path:
|
||||
p = Path.home() / "Library" / "LaunchAgents"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p / f"{LAUNCH_AGENT_ID}.plist"
|
||||
|
||||
|
||||
def _mac_program_arguments() -> list[str]:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
return [str(root / "scripts" / "run_proxy_god.sh")]
|
||||
|
||||
|
||||
def _install_launch_agent() -> tuple[bool, str]:
|
||||
plist_path = _launch_agent_path()
|
||||
data = {
|
||||
"Label": LAUNCH_AGENT_ID,
|
||||
"ProgramArguments": _mac_program_arguments(),
|
||||
"RunAtLoad": True,
|
||||
"WorkingDirectory": str(Path(__file__).resolve().parent.parent),
|
||||
"StandardOutPath": str(Path.home() / "Library" / "Logs" / "ProxyGod.out.log"),
|
||||
"StandardErrorPath": str(Path.home() / "Library" / "Logs" / "ProxyGod.err.log"),
|
||||
}
|
||||
try:
|
||||
plist_path.write_bytes(plistlib.dumps(data))
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, timeout=10)
|
||||
r = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True, timeout=10)
|
||||
out = (r.stdout or "") + (r.stderr or "")
|
||||
return r.returncode == 0, out.strip() or "LaunchAgent installed"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _uninstall_launch_agent() -> tuple[bool, str]:
|
||||
plist_path = _launch_agent_path()
|
||||
try:
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, timeout=10)
|
||||
plist_path.unlink(missing_ok=True)
|
||||
return True, "LaunchAgent removed"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _write_launcher_cmd() -> Path:
|
||||
@@ -30,6 +73,8 @@ def _write_launcher_cmd() -> Path:
|
||||
|
||||
|
||||
def install_logon_task() -> tuple[bool, str]:
|
||||
if sys.platform == "darwin":
|
||||
return _install_launch_agent()
|
||||
launcher = _write_launcher_cmd()
|
||||
tr = str(launcher)
|
||||
try:
|
||||
@@ -58,6 +103,8 @@ def install_logon_task() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def uninstall_logon_task() -> tuple[bool, str]:
|
||||
if sys.platform == "darwin":
|
||||
return _uninstall_launch_agent()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["schtasks", "/Delete", "/F", "/TN", TASK_NAME],
|
||||
@@ -72,6 +119,8 @@ def uninstall_logon_task() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def task_exists() -> bool:
|
||||
if sys.platform == "darwin":
|
||||
return _launch_agent_path().is_file()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["schtasks", "/Query", "/TN", TASK_NAME],
|
||||
|
||||
Reference in New Issue
Block a user