From 35899ba1d1464ad36487fd66a7dd6db3afc996ba Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 23 May 2026 22:09:43 -0700 Subject: [PATCH] harden macOS networking port --- README.md | 14 +- docs/AUDIT_FINDINGS.md | 318 ------------------------ docs/MAC_OPERATOR_NOTES.md | 44 ++++ docs/MAINTENANCE.md | 23 -- docs/OPERATOR_RUNBOOK.md | 128 ---------- docs/PROJECT_HANDOFF.md | 14 -- docs/PROVENANCE_CHECKLIST.md | 12 - docs/RELEASE_PROCESS.md | 92 ------- docs/ROADMAP.md | 20 -- docs/SECURITY_REVIEW.md | 14 -- proxy_chain_manager/__init__.py | 2 +- proxy_chain_manager/app.py | 214 +++++++++------- proxy_chain_manager/artifact_wipe.py | 29 +++ proxy_chain_manager/config.py | 13 + proxy_chain_manager/fingerprint.py | 97 ++++++++ proxy_chain_manager/leak_audit.py | 38 ++- proxy_chain_manager/mac_spoof.py | 21 ++ proxy_chain_manager/macos_privileged.py | 45 ++++ proxy_chain_manager/privacy_lan.py | 11 + proxy_chain_manager/service.py | 39 +-- proxy_chain_manager/sysproxy.py | 63 ++++- proxy_chain_manager/telemetry_kill.py | 7 + proxy_chain_manager/webrtc_check.py | 16 +- proxy_chain_manager/windows_task.py | 49 ++++ 24 files changed, 581 insertions(+), 742 deletions(-) delete mode 100644 docs/AUDIT_FINDINGS.md create mode 100644 docs/MAC_OPERATOR_NOTES.md delete mode 100644 docs/MAINTENANCE.md delete mode 100644 docs/OPERATOR_RUNBOOK.md delete mode 100644 docs/PROJECT_HANDOFF.md delete mode 100644 docs/PROVENANCE_CHECKLIST.md delete mode 100644 docs/RELEASE_PROCESS.md delete mode 100644 docs/ROADMAP.md delete mode 100644 docs/SECURITY_REVIEW.md create mode 100644 proxy_chain_manager/macos_privileged.py diff --git a/README.md b/README.md index 4f3d0ed..e23734c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,19 @@ The first run creates `.venv`, installs dependencies, downloads the correct macO - macOS app-data paths under `~/Library/Application Support/ProxyChainManager`. - macOS GOST download/extract for `darwin_arm64` or `darwin_amd64`. - macOS DNS cache flush and system proxy apply/clear through `networksetup`. +- macOS launch-at-login through a per-user LaunchAgent. +- macOS hostname spoof and IPv6-off controls with standard administrator prompts. ## Platform Notes -The upstream project is Windows-first. Windows-only controls such as netsh firewall kill-switch, registry WebRTC policy, Task Scheduler boot task, telemetry kill, and LAN lockdown are kept in the UI but degrade safely on macOS. The core proxy chain engine, validation, map/intel, logs, browser profile generation, ban tests, signup prep, and settings are runnable on macOS. +The upstream project is Windows-first. Features that do not have a clean, safe macOS equivalent were removed from the Mac UI instead of being left as fake toggles: netsh firewall kill-switch, registry Chrome/Edge WebRTC policy, MAC spoofing, Windows telemetry kill, and Windows LAN broadcast lockdown. + +macOS networking is handled through: + +- GOST bound to loopback at `127.0.0.1`. +- System HTTP/HTTPS proxy apply/clear via `networksetup`. +- Administrator prompt fallback when macOS requires permission for network changes. +- DNS cache flush with `dscacheutil` and `mDNSResponder`. +- Hardened Firefox profile for WebRTC and fingerprint controls. + +The global Windows fail-closed firewall was not ported because a safe one-click macOS PF equivalent would require managing privileged packet-filter state and can leave the machine offline if interrupted. The Mac build favors deterministic system-proxy cleanup and verified chain health over unsafe PF mutation. diff --git a/docs/AUDIT_FINDINGS.md b/docs/AUDIT_FINDINGS.md deleted file mode 100644 index bdd98aa..0000000 --- a/docs/AUDIT_FINDINGS.md +++ /dev/null @@ -1,318 +0,0 @@ -# Proxy God — Audit Findings - -**Generated:** 2026-05-22 -**Tests:** 47/47 pass · compileall clean -**Severity summary:** P0: 12 · P1: 28 · P2: 22 · **Total: 62** - ---- - -## Executive Summary - -| Severity | Count | -|----------|------:| -| Critical (P0) | 12 | -| High (P1) | 28 | -| Medium (P2) | 22 | -| **Total** | **62** | - -**Already remediated (prior passes):** CI workflow, GOST SHA256 pin, kill-switch `emergency_disengage` + atexit, settings migration/backup, log rotation, MIT LICENSE, GOST SHA256 verified correct. - ---- - -## Top P0 Issues - -1. **Wrong Chrome/Edge WebRTC policy key/value** — `fingerprint.py:21,177` writes DWORD `2` which is `default_public_and_private_interfaces`, NOT `disable_non_proxied_udp` (DWORD `3`). Modern Chrome also requires REG_SZ `WebRtcIPHandling = disable_non_proxied_udp`. Audits treat `2` as success → false green. -2. **`BrowserSession.stop()` kills ALL Firefox on the machine** — `browser_launcher.py:297-305` runs `taskkill /F /IM firefox.exe` with no PID scope. Operator loses unrelated browser sessions. -3. **Credentials plaintext at rest** — `config.py:19-22`, `signup_prep.py:252-325` store proxy auth + signup passwords in JSON. `cryptography==48.0.0` is in requirements but unused. -4. **Preflight race condition** — `app.py:2001-2009` uses fixed `root.after(200, _final)` which can report "All passed" before async network checks finish. -5. **Preflight IP compare is exact-match only** — ignores the VPN-aware `/16` logic used in production leak detection (`leak_detect.py:24-38`). -6. **HTTPS CONNECT failure doesn't stop chain** — `service.py:573-580` logs warning only; chain stays "healthy" and system proxy remains set. -7. **Pinned manual chain never validates hops at runtime** — dead chain retries every 10s forever (`service.py:393-403`). -8. **`emergency_disengage()` runs on ALL exits** — even if kill-switch never engaged; can mask other apps' firewall state (`service.py:105`, `firewall.py:165-177`). -9. **Signup extension runs on all URLs** — `signup_extension/manifest.json:13-19` uses ``. -10. **Account auto-saved before signup completes** — password saved on browser launch before user registers (`app.py:2147-2174`). -11. **GOST exe not re-hashed on reuse** — zip is verified but extracted `gost.exe` on disk is not re-checked (`gost_util.py:61-64`). -12. **`asyncio.run()` from worker threads** — `app.py:1860-1863` nested event loop risk if caller context changes. - ---- - -## 1. Critical Bugs - -| ID | Finding | File:Line | -|----|---------|-----------| -| C-01 | WebRTC policy wrong key/type/value | `fingerprint.py:18-21,162-177`, `webrtc_check.py:37-96`, `leak_audit.py:138-157` | -| C-02 | Browser stop kills all `firefox.exe` globally | `browser_launcher.py:297-305` | -| C-03 | Plaintext `settings.json` / signup JSON credentials | `config.py:19-22`, `signup_prep.py:252-325` | -| C-04 | Preflight `root.after(200)` race — final verdict before checks complete | `app.py:2001-2009` | -| C-05 | Preflight IP compare exact-match only (ignores /16 VPN logic) | `app.py:1886-1893`, `leak_detect.py:24-38` | -| C-06 | HTTPS CONNECT failure keeps chain "healthy" | `service.py:573-580` | -| C-07 | Pinned chain never validates hops at runtime | `service.py:393-403` | -| C-08 | `emergency_disengage()` fires on ALL exits | `service.py:105`, `firewall.py:165-177` | -| C-09 | Signup extension `` permissions | `signup_extension/manifest.json:13-19` | -| C-10 | Account saved before signup completes | `app.py:2147-2174` | -| C-11 | GOST exe not re-hashed on disk reuse | `gost_util.py:61-64` | -| C-12 | `asyncio.run()` from worker threads | `app.py:1860-1863` | - ---- - -## 2. Edge Cases / Failure Modes - -| ID | Finding | File:Line | -|----|---------|-----------| -| E-01 | `real_ip` captured once at start; VPN reconnect mid-session not reflected | `service.py:339-340,551-558` | -| E-02 | Sticky exit suppresses leak rotation but not manual rotate/GOST death | `service.py:607-619` | -| E-03 | Sticky + health leak: status forced "healthy" while leaking | `service.py:610-618` | -| E-04 | `is_chain_leak(real_ip=None)` returns False (no leak) — fail-open | `leak_detect.py:32-33` | -| E-05 | Same /16 ISP neighbors never flagged — some leak modes missed by design | `leak_detect.py:36-37` | -| E-06 | Empty pinned chain + `use_pinned_chain=True` falls through to pool silently | `service.py:393`, `config.py:201` | -| E-07 | `chain_length=1` + manual exit only — no mid-hop redundancy | `service.py:407-415` | -| E-08 | Obfuscation `random_mix` shuffles list but takes prefix slice — not random per hop | `service.py:641-644,717` | -| E-09 | Pool exhausted → reshuffle; blacklisted proxies stay excluded until refresh | `service.py:409-417,768-771` | -| E-10 | Fixed exit validation failure still used in chains | `service.py:822-839` | -| E-11 | `socket.setdefaulttimeout()` global mutation — thread race in DNS helpers | `dns_leak.py:140-151`, `ban_tester.py:149-150` | -| E-12 | DNS leak "proxy path" uses Google DoH through HTTP proxy — not equivalent to system DNS | `dns_leak.py:154-167` | -| E-13 | DNS `all_match` logic can false-flag or miss due to CDN/geo DNS answers | `dns_leak.py:221-252` | -| E-14 | Kill-switch allows outbound DNS globally — DNS exfil channel exists | `firewall.py:136-140` | -| E-15 | Kill-switch skipped when non-admin with only log line | `service.py:350-357` | -| E-16 | IPv6 disable skips virtual adapters by name heuristics only | `fingerprint.py:122-137` | -| E-17 | Hostname rename may require reboot; partial apply possible | `fingerprint.py:73-103` | -| E-18 | MAC spoof restore depends on in-memory originals — crash mid-session may leave spoofed MAC | `service.py:96-97` | -| E-19 | `BrowserSession.is_running()` treats any `firefox.exe` within 5 min as "ours" | `browser_launcher.py:153-157` | -| E-20 | Auto-relaunch can loop if profile locked/corrupt | `app.py:2866-2871` | -| E-21 | `task_exists()` logon task runs LIMITED — no admin kill-switch at boot | `windows_task.py:47-48` | -| E-22 | Group Policy proxy locks detected but never remediated | `sysproxy.py:298-317`, `service.py:586-594` | -| E-23 | `load_settings` auto-rewrites sanitized file — can surprise operators | `config.py:436-443` | -| E-24 | `_is_safe_https_url` allows `http://` sources — MITM on pool fetch | `config.py:266-271`, `fetcher.py:13-16` | -| E-25 | Ban tester DNS leak helper checks `x-real-ip` header (usually absent) — nonsense results | `ban_tester.py:169-219` | -| E-26 | Ban tester `_looks_banned_body` matches "captcha" on normal pages → false bans | `ban_tester.py:71-89,133-134` | -| E-27 | `run_ban_tests(categories=["All"])` duplicates sites | `ban_tester.py:237-243` | -| E-28 | Exit intel/geo fetch via third-party APIs through proxy — telemetry surface | `exit_intel.py`, `chain_map.py:152-179` | -| E-29 | World map geo resolution uses direct `socket.getaddrinfo` — DNS leak for hop lookup | `chain_map.py:86-112,160` | -| E-30 | GOST immediate exit blacklists hops but pinned/manual exit exempt | `service.py:515-522,557-562` | - ---- - -## 3. Missing Features / README Gaps - -| ID | Finding | Reference | -|----|---------|-----------| -| M-01 | README "self-healing forever" — pinned mode doesn't rotate pool | `README.md:9`, `service.py:393-403` | -| M-02 | README "fail closed" — non-admin + kill-switch skipped | `README.md:25`, `service.py:350-357` | -| M-03 | README 1–8 hops; UI/config says 2–8 in places | `README.md:48`, `config.py:167` | -| M-04 | No in-GUI emergency kill-switch disengage button | `firewall.py`, `app.py` | -| M-05 | `audit_device()` is OS-only but UI shows it under "Browser Fingerprint" | `fingerprint.py:299-383`, `app.py:2310-2313` | -| M-06 | Preflight card mentions "fingerprint" but no fingerprint check in `_PF_CHECKS` | `app.py:1775-1793` | -| M-07 | `browser_auto_relaunch` not tied to chain health flag | `app.py:2866-2871`, `config.py:205` | -| M-08 | No export/import encrypted settings/signup vault | — | -| M-09 | No persisted ban-test / audit history | `app.py` | -| M-10 | `prefer_elite` can empty pool with no GUI recovery wizard | `config.py:183`, `service.py:754-761` | -| M-11 | SOCKS5 remote DNS disabled in Firefox profile | `browser_profile.py:125` | -| M-12 | Signup presets have no ToS/disclaimer in UI | `signup_prep.py:37-160` | -| M-13 | No IPv6 chain path — IPv6 disable adapter-level only | `fingerprint.py:106-138` | -| M-14 | Tray red/yellow/green with no click-through to failed check detail | `tray.py`, `app.py:2841-2844` | -| M-15 | `CHANGELOG.md` claims DPAPI/Fernet — not implemented | `CHANGELOG.md:35-36` | - ---- - -## 4. Build / Packaging / Deployment - -| ID | Finding | Reference | -|----|---------|-----------| -| B-01 | No Authenticode signing | `ProxyChainManager.spec:70-71` | -| B-02 | UPX enabled — AV false positives risk | `ProxyChainManager.spec:66-67` | -| B-03 | `uac_admin=True` — every launch elevates | `ProxyChainManager.spec:72` | -| B-04 | PyInstaller not pinned in `requirements.txt` | `scripts/setup_and_build.ps1:39` | -| B-05 | Build script doesn't emit SHA256 sidecar | `scripts/setup_and_build.ps1:50-58` | -| B-06 | GOST not bundled — first run requires GitHub access | `gost_util.py:61-86` | -| B-07 | `__version__` static `1.0.0`, not tied to git tag | `__init__.py:3` | -| B-08 | CI does not run PyInstaller build | `.github/workflows/test.yml` | -| B-09 | No SBOM / `pip freeze` in release artifacts | — | -| B-10 | Defender exclusion adds whole `%LOCALAPPDATA%\ProxyChainManager` folder | `gost_util.py:29-45` | - ---- - -## 5. Security / Privacy - -| ID | Finding | Reference | -|----|---------|-----------| -| S-01 | Plaintext secrets at rest (proxy creds + signup passwords) | `config.py`, `signup_prep.py` | -| S-02 | WebRTC mis-hardening (wrong policy value) | `fingerprint.py` | -| S-03 | All httpx clients use `verify=False` — MITM risk | `validator.py:142`, `ban_tester.py:119`, `dns_leak.py:159` | -| S-04 | Signup passwords can appear in UI/log via clipboard actions | `app.py:2059-2062` | -| S-05 | Signup extension content script on all URLs | `signup_extension/manifest.json` | -| S-06 | Public proxy list sources — hostile infrastructure by design | `config.py:218-223` | -| S-07 | `cryptography` dependency unused — attack surface without benefit | `requirements.txt:5` | -| S-08 | Kill-switch + DNS allow rule = DNS bypass channel | `firewall.py:136-140` | -| S-09 | Forensic wipe deletes Recycle Bin without extra confirmation | `artifact_wipe.py:208-216` | -| S-10 | No secure deletion of signup JSON on uninstall | — | - ---- - -## 6. UI/UX Issues - -| ID | Finding | Reference | -|----|---------|-----------| -| U-01 | Window close minimizes to tray — chain/firewall active silently | `app.py:2908` | -| U-02 | "Run as Admin" relaunch closes app — unsaved UI state lost | `app.py:347-350` | -| U-03 | No version/build SHA in About UI | `__init__.py:3` | -| U-04 | Cookie/persona menus reverse-lookup by label — fragile if labels change | `app.py:1321-1325` | -| U-05 | Hardened toggles visible even when blend persona overrides them | `browser_profile.py:42-65` | -| U-06 | Preflight "fingerprint" in subtitle but not in checklist | `app.py:1775-1793` | -| U-07 | Ban tester has no progress bar for 30+ sites | `ban_tester.py:222-260` | -| U-08 | Saved accounts list shows email + password in plain text | `app.py:2046-2049` | -| U-09 | Chain map silently skips hops when geo lookup fails — no unknown marker | `chain_map.py:156-166,322-328` | -| U-10 | `verbose_logs_var` affects UI handler only; file log stays INFO | `app.py:707-713` | - ---- - -## 7. Test Coverage Gaps - -| ID | Finding | -|----|---------| -| T-01 | No tests for `service.py` (rotation, sticky, kill-switch) | -| T-02 | No tests for `firewall.py` / `emergency_disengage` | -| T-03 | No tests for `sysproxy.py` registry blob encoding | -| T-04 | No tests for GOST SHA256 mismatch path | -| T-05 | No tests for `dns_leak.run_dns_leak_test` verdict logic | -| T-06 | No tests for `webrtc_check` / `apply_webrtc_hardening` | -| T-07 | No tests for `BrowserSession` | -| T-08 | No tests for `signup_prep.install_signup_extension` | -| T-09 | No tests for `config.load_settings`/`migrate`/`sanitize` round-trip | -| T-10 | No tests for `ban_tester` heuristics | -| T-11 | No GUI/smoke tests for `app.py` (~2900 lines) | -| T-12 | `test_fetch_smoke` is network-dependent — can skip silently in CI | -| T-13 | No test that WebRTC policy value matches Chrome documentation | -| T-14 | No frozen-bundle (`_MEIPASS`) path tests in CI | - ---- - -## 8. Recommended Fix Priority - -**P0 — Fix immediately:** -C-01, C-02, C-03, C-04, C-05, C-06, S-01, S-02, S-03, B-01 - -**P1 — Next sprint:** -C-07, C-10, E-01–E-06, E-11, E-22, E-25–E-27, M-04–M-07, M-11, B-04–B-06, T-01–T-05, T-09, U-01–U-03 - -**P2 — Backlog:** -All remaining E/M/B/U/T items, installer signing, metrics, coverage gates - ---- - -## 9. Remediation log (2026-05-22 passes) - -| ID | Status | Notes | -|----|--------|-------| -| C-01 | **Fixed** | `fingerprint.py`, `webrtc_check.py` — DWORD `3` + `WebRtcIPHandling` REG_SZ | -| C-02 | **Fixed** | `browser_launcher.py` — scoped `taskkill /PID` | -| C-04 | **Fixed** | `app.py` — `_final` scheduled after all `after(0)` callbacks | -| C-05 | **Fixed** | `app.py` preflight uses `is_chain_leak()` + VPN-aware `/16` | -| C-06 | **Fixed** | `service.py` — HTTPS CONNECT failure rotates chain (not healthy) | -| C-08 | **Fixed** | `firewall.py` — `emergency_disengage()` no-op unless `is_engaged()` | -| C-10 | **Fixed** | `app.py` — auto-save on launch is `pending` with empty password | -| E-06 | **Fixed** | `service.py` — log when pinned chain enabled but empty | -| E-11 | **Fixed** | `dns_leak.py`, `ban_tester.py` — restore socket timeout in `finally` | -| E-24 | **Fixed** | `config.py` — pool sources must be `https://` only | -| E-26 | **Fixed** | `ban_tester.py` — removed bare `"captcha"` hint; stricter phrases | -| E-27 | **Fixed** | `ban_tester.py` — dedupe by URL | -| M-06 / U-06 | **Fixed** | Preflight subtitle no longer claims “fingerprint” check | -| M-15 | **Fixed** | `CHANGELOG.md` — Fernet/DPAPI not implemented (clarified) | -| B-02 | **Fixed** | `ProxyChainManager.spec` — `upx=False` | -| leak_audit | **Fixed** | `_check_webrtc_policy()` accepts DWORD `3` or REG_SZ | -| (prior) | **Fixed** | CI, GOST zip SHA256, settings migration/backup, LICENSE, log rotation | - ---- - -## 10. Still needs to be fixed - -Items below were **not** changed because they need design decisions, external tooling (certs, infra), or behavior that is not safe to guess. - -### Critical / security (P0) - -| ID | Why left open | Suggested direction | -|----|---------------|---------------------| -| **C-09** | Narrowing signup extension off `` breaks **custom signup URLs** the operator types in | Dynamic host permissions API or per-session host approval prompt | -| **C-12** | `asyncio.run()` in worker threads (`app.py` preflight) needs a dedicated event-loop policy refactor | Move all preflight network ops to a single asyncio runner thread | -| **S-03** | `verify=False` on httpx is **intentional** for the broken-cert reality of public proxies | Per-setting toggle with security warning, documented trade-off | -| **B-01** | Authenticode signing needs an actual code-signing certificate + CI secrets | Sign `dist\ProxyChainManager.exe` in release pipeline once cert is provisioned | - -### High (P1) — design / scope debt - -| ID | Why left open | -|----|---------------| -| **E-01** | Refresh `real_ip` when VPN state changes mid-session — needs interval policy | -| **E-02–E-05** | Sticky exit / leak semantics need a product rule (rotate vs warn) | -| **E-07–E-10** | Pool/pinned/exit edge cases need operator UX | -| **E-12–E-13** | DNS leak methodology (DoH vs system DNS) needs spec | -| **E-14 / S-08** | Kill-switch DNS allow rule is required for GOST hostname resolution | -| **E-16–E-18** | OS adapter / MAC spoof crash recovery | -| **E-21** | Boot task LIMITED vs admin kill-switch | -| **E-22** | Group Policy proxy-lock remediation (destructive — needs explicit operator consent) | -| **E-23** | Auto-rewrite of sanitized settings — needs UI toggle | -| **E-28–E-30** | Third-party intel APIs / map geo / GOST blacklist rules | -| **M-01–M-03, M-05, M-07–M-14** | README alignment, encrypted vault export, ban history, IPv6 chain path, tray click-through | -| **U-01, U-02, U-04, U-05, U-07–U-10** | Tray/minimize UX, admin-relaunch save state, cookie menu keys, password masking | -| **T-01–T-14** | Larger test-coverage program | -| **B-03, B-06–B-10** | UAC manifest tradeoff, GOST bundling, SBOM, Defender scope | - -### Medium (P2) - -Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS policy (`browser_profile.py:125` — likely intentional for Firefox+GOST). - ---- - -## 12. Decisions held — accepted trade-offs - -These items are **closed by design choice** (operator approved), not because they're hidden bugs. - -| ID | Decision | Rationale | -|----|----------|-----------| -| **C-09** | Keep signup extension on `` | Custom signup URLs entered at runtime require dynamic host match; operator isolates the profile per session. | -| **S-03** | Keep `verify=False` on httpx probes | Public proxies routinely ship broken / self-signed TLS; turning verification on would drop ~half the working pool. | -| **E-14 / S-08** | Keep DNS pass-through in kill-switch | GOST needs system DNS to resolve proxy hostnames; locking port 53 would break pool fetching and exit verification. | -| **B-01** | Code-signing deferred | Requires an Authenticode certificate (commercial or self-signed); will wire `signtool sign` into the build script once the cert is provided. | -| **B-08** | CI builds the exe — pending operator approval | Adds ~2 min per CI run; not enabled yet. | - ---- - -## 11. Remediation log — 2026-05-22 (round 2) - -| ID | Status | Notes | -|----|--------|-------| -| **C-03 / S-01** | **Fixed** | `secrets_store.py` (Windows DPAPI via ctypes — no new deps). Signup passwords + draft passwords now encrypted on disk; legacy plaintext migrates automatically on first save | -| **C-07** | **Fixed** | New `validate_pinned_on_start` setting + `_probe_tcp` helper in `service.py`; dead pinned hops logged but used | -| **C-11** | **Fixed** | `gost_util.py` writes `gost.exe.sha256` sidecar on extract and re-verifies on every reuse; mismatch triggers re-download | -| **E-15** | **Fixed** | `app.py` `_start()` confirms with operator before starting chain without admin when kill-switch is enabled | -| **E-19** | **Fixed** | `browser_launcher.py` `is_running()` walks the spawned PID's descendants via WMIC instead of pattern-matching any `firefox.exe` | -| **E-20** | **Fixed** | `max_browser_relaunches` setting + counter in `app.py`; auto-relaunch self-disables after N failures | -| **E-25** | **Fixed** | Dead `ban_tester.check_dns_leak()` removed (use `dns_leak.run_dns_leak_test`) | -| **M-04** | **Fixed** | Settings tab now has “⚠ Emergency disengage firewall now” button with confirm | -| **U-03** | **Fixed** | Window title now reads `Proxy God v{__version__}` | -| **B-04** | **Fixed** | New `dev-requirements.txt` pins `pyinstaller==6.10.0`; build script installs it | -| **B-05** | **Fixed** | Build script emits `ProxyChainManager.exe.sha256` next to the exe and copies it to Desktop | - -**Test suite:** 47 → **53 passing** (added 6 round-trip tests for `secrets_store`). - ---- - -*Proxy God Audit — last updated 2026-05-22 (round 2)* - ---- - -## 13. Remediation log — 2026-05-22 (round 3) - -| ID | Status | Notes | -|----|--------|-------| -| **E-04** | **Fixed (fail-closed)** | `leak_detect.is_chain_leak()` now treats unknown `real_ip` as a leak; `service.py` retries direct-IP lookup 3× with 2 s back-off as warm-up before the verdict applies. New `tests/test_fail_closed.py` enforces the contract. | -| **C-12** | **Fixed** | Preflight worker thread now creates ONE `asyncio` event loop, runs all coroutines on it via `_run_async()`, and closes it at the end. No more nested-loop risk. | -| **U-01** | **Fixed** | X button asks the operator (Yes=tray / No=full quit / Cancel) when chain or firewall is still active. | -| **U-02** | **Fixed** | "Run as Admin" calls `_save_settings()` before relaunching so unsaved Chain Builder / Settings edits persist. | -| **U-04** | **Fixed** | Persona / cookie OptionMenus now reverse-lookup via a precomputed `label→key` dict — copy changes can no longer break the round-trip. | -| **B-06** | **Fixed** | `scripts/prepare_bundled_gost.ps1` stages `proxy_chain_manager/_bundled/gost.exe` (SHA-verified) before PyInstaller; the spec bundles it; `ensure_gost()` installs from bundle first and only network-downloads as fallback. First run works fully offline. | -| **T-coverage** | **Fixed** | New tests: `test_config_round_trip.py` (sanitize / migrate / save-load / corrupt backup), `test_ban_tester.py` (banned-body heuristics, categories), `test_gost_util.py` (zip + exe hash sidecar), `test_fail_closed.py` (leak contract), `test_firewall_helpers.py` (emergency_disengage guard). **47 → 82 tests passing** (+35 since session start). | - -**No-action documented (§12):** C-09, S-03, E-14/S-08, B-01, B-08. - ---- - -*Proxy God Audit — last updated 2026-05-22 (round 3)* diff --git a/docs/MAC_OPERATOR_NOTES.md b/docs/MAC_OPERATOR_NOTES.md new file mode 100644 index 0000000..4c5fff1 --- /dev/null +++ b/docs/MAC_OPERATOR_NOTES.md @@ -0,0 +1,44 @@ +# Proxy God Mac Operator Notes + +## Launch + +Use `make run`, `run_mac.command`, or `open "Proxy God.app"`. + +The launcher chooses a Python runtime with Tk support. If Homebrew Python lacks +`_tkinter`, it will use a Python.org or system Tk-capable runtime instead. + +## Networking Permissions + +Proxy God applies and clears macOS HTTP/HTTPS proxy settings with +`networksetup`. If macOS requires elevated permission, the app uses the standard +administrator prompt through `osascript`. + +## Removed Windows-Only Controls + +The Mac build intentionally does not expose: + +- Windows `netsh` firewall kill-switch +- Windows registry Chrome/Edge WebRTC policy +- Windows telemetry kill +- Windows LAN broadcast lockdown +- Windows MAC spoofing + +The equivalent Mac posture is loopback-only GOST, system proxy enforcement, +DNS flush, hardened Firefox profile controls, chain exit verification, and +health-triggered rotation. + +## Cleanup + +On stop or quit, the app terminates GOST and clears macOS system proxy settings. +If a manual cleanup is ever needed: + +```sh +networksetup -setwebproxystate "Wi-Fi" off +networksetup -setsecurewebproxystate "Wi-Fi" off +``` + +Repeat for any active network service shown by: + +```sh +networksetup -listallnetworkservices +``` diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md deleted file mode 100644 index ab41028..0000000 --- a/docs/MAINTENANCE.md +++ /dev/null @@ -1,23 +0,0 @@ -# Maintenance - - -## Stewardship Rules - -- Keep generated files, build outputs, copied SDKs, and raw firmware binaries out of Git unless they are the source of truth. -- Keep credentials, tokens, dumps, private messages, session stores, and local machine paths out of commits. -- Prefer small commits with clear intent and a matching issue or release note. -- Preserve upstream attribution when code is copied, forked, or adapted. - -## Routine Checks - -- README still describes what the project does. -- Setup instructions still work. -- Security policy is accurate for the current risk level. -- Changelog records user-visible changes. -- License status is explicit. - -## Automation Gate - -- Confirm no tokens, session cookies, personal data, or exported credentials are committed. -- Document required environment variables with safe example values only. -- Add rate-limit and account-safety notes before any release. diff --git a/docs/OPERATOR_RUNBOOK.md b/docs/OPERATOR_RUNBOOK.md deleted file mode 100644 index 68dce5d..0000000 --- a/docs/OPERATOR_RUNBOOK.md +++ /dev/null @@ -1,128 +0,0 @@ -# Proxy God — Operator Runbook - -**Version:** 1.0 -**Last updated:** 2026-05-21 - ---- - -## Emergency: Firewall Kill-Switch Stuck (Network Appears Offline) - -If the application crashes or is force-killed while the kill-switch is engaged, -outbound traffic will remain blocked. Follow these steps to restore connectivity. - -### Option 1 — Run the standalone disengage script (fastest) - -Open an **elevated** (Run as Administrator) PowerShell terminal and run: - -```powershell -# Remove all PCM_ firewall rules -netsh advfirewall firewall delete rule name=all -# Restore default outbound policy -netsh advfirewall set allprofiles firewallpolicy blockinbound,allowoutbound -``` - -### Option 2 — Restart the application elevated - -1. Right-click `ProxyChainManager.exe` → **Run as administrator**. -2. The application detects orphan firewall rules on startup and removes them. -3. Click **Stop** if the service does not start, to force disengage. - -### Option 3 — Restart Windows Firewall service - -```powershell -Restart-Service -Name MpsSvc -Force -``` - -This resets all runtime firewall state (not persistent rules). You may still -need to remove the `PCM_*` rules afterward with Option 1. - -### How the application handles this automatically - -`firewall.py` exposes `emergency_disengage()`, which is registered via -`atexit` and `signal.SIGTERM`/`SIGINT` in `service.py`. A clean exit or -SIGTERM will call `emergency_disengage()` automatically, removing all -`PCM_*` rules and restoring `allowoutbound` before the process terminates. -Only a hard kill (`SIGKILL`, power loss, BSOD) can bypass this handler — -in those cases use Option 1 or Option 2 above. - -### Verify rules are gone - -```powershell -netsh advfirewall firewall show rule name=all dir=out | Select-String "PCM_" -# Should return no output when clean -``` - ---- - -## Emergency: Proxy Leak Detected - -If the Live tab reports a leak (exit IP matches your real IP or VPN IP): - -1. Click **Rotate Now** to pick fresh proxies. -2. If leaks persist, click **Stop**, then re-enable the kill-switch and click **Start**. -3. Check the pool size — if < 5 proxies, force a full pool refresh by stopping - and restarting the service. -4. Enable VPN before starting if leak detection mode is "strict (exact IP)" — - a VPN provides a larger subnet mask for the leak check. - ---- - -## Empty Pool / CDN Down - -If every pool refresh returns 0 proxies: - -1. Check internet connectivity (browser → any site through system proxy OFF). -2. Verify the proxy source URLs in **Settings** are reachable. -3. The default sources use `cdn.jsdelivr.net` — if blocked on your network, - replace with a mirror or local JSON file (use `file:///path/to/proxies.json` - format). -4. Temporarily lower `Max candidates` to speed up validation if the CDN is - slow. - ---- - -## GOST Binary Missing or Quarantined - -Symptoms: "GOST setup failed" or "GOST appears quarantined" in the Live log. - -1. Check Windows Defender: **Windows Security → Protection history** — look for - a quarantine event on `gost.exe`. -2. Restore and exclude `gost.exe` from Defender, or re-download by deleting the - GOST folder: - - ```powershell - Remove-Item "$env:LOCALAPPDATA\ProxyChainManager\gost" -Recurse -Force - ``` - -3. Restart the application — it will re-download and re-verify GOST via SHA256. - ---- - -## Settings Corrupted / Reset to Defaults - -A corrupt `settings.json` is automatically backed up to `settings.json.corrupt` -and the application reverts to defaults. - -To restore: - -```powershell -$dir = "$env:LOCALAPPDATA\ProxyChainManager" -Copy-Item "$dir\settings.json.corrupt" "$dir\settings.json" -Force -``` - -A rolling backup is also kept at `settings.json.bak` (last successful save). - ---- - -## Log Files - -| File | Location | Purpose | -|------|----------|---------| -| `proxy_chain_manager.log` | `%LOCALAPPDATA%\ProxyChainManager\` | Main app log (5 MB × 3 backups) | -| `gost.log` | `%LOCALAPPDATA%\ProxyChainManager\` | GOST process output (512 KB rolling) | - -To open the log directory: - -```powershell -explorer "$env:LOCALAPPDATA\ProxyChainManager" -``` diff --git a/docs/PROJECT_HANDOFF.md b/docs/PROJECT_HANDOFF.md deleted file mode 100644 index bf908fc..0000000 --- a/docs/PROJECT_HANDOFF.md +++ /dev/null @@ -1,14 +0,0 @@ -# Project Handoff - - -## What This Repo Needs From A Maintainer - -- A one-paragraph project summary in README.md. -- Confirmed setup instructions. -- Confirmed license status. -- Confirmed provenance for imported code and binaries. -- A known-good verification command, test, build, flash, or demo path. - -## Current Stewardship State - -This repo has baseline governance files, wiki pages, issue templates, labels, milestones, and a readiness issue. The next maintainer should replace generic stewardship notes with project-specific facts. diff --git a/docs/PROVENANCE_CHECKLIST.md b/docs/PROVENANCE_CHECKLIST.md deleted file mode 100644 index 8d41990..0000000 --- a/docs/PROVENANCE_CHECKLIST.md +++ /dev/null @@ -1,12 +0,0 @@ -# Provenance Checklist - - -Use this before claiming ownership or publishing artifacts. - -- [ ] Identify original upstream source, if any. -- [ ] Record fork URL, commit, tag, or archive source. -- [ ] Preserve third-party notices and license files. -- [ ] Separate local patches from imported code where practical. -- [ ] Record binary build inputs, toolchain versions, and source commit. -- [ ] Publish checksums for release assets. -- [ ] Mark unknown-origin content as blocked until resolved. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md deleted file mode 100644 index 0a90369..0000000 --- a/docs/RELEASE_PROCESS.md +++ /dev/null @@ -1,92 +0,0 @@ -# Release Process - -Production Windows builds use **PyInstaller** with a gated pipeline in `scripts/release_build.ps1`. - -## Quick commands - -| Goal | Command | -|------|---------| -| **Production release** | `build_release.bat` or `powershell -File .\scripts\release_build.ps1` | -| **Dev build (Desktop copy)** | `build_exe.bat` or `powershell -File .\scripts\setup_and_build.ps1` | -| **Skip tests (dev only)** | `powershell -File .\scripts\setup_and_build.ps1 -SkipTests` | - -## Production pipeline (`release_build.ps1`) - -1. Resolve Python 3.10+ -2. Install `requirements.txt` + `dev-requirements.txt` (PyInstaller **6.10.0** pinned) -3. **Test gate**: `compileall` + `unittest discover` (skip with `-SkipTests` — not for prod) -4. Download & SHA-verify bundled `gost.exe` → `proxy_chain_manager/_bundled/` -5. Generate Windows **VERSIONINFO** → `build/version_info.txt` -6. **PyInstaller** one-file build via `ProxyChainManager.spec` -7. Optional **Authenticode** sign (see below) -8. SHA256 sidecar for the exe -9. **SBOM** (`SBOM.json` + `requirements-frozen.txt`) -10. **RELEASE_MANIFEST.json** (version, commit, sha256, build time) -11. Zip → `releases/ProxyGod-v{version}-windows-amd64.zip` - -### Output layout - -``` -releases/ - v1.0.0/ - ProxyChainManager.exe - ProxyChainManager.exe.sha256 - RELEASE_MANIFEST.json - SBOM.json - requirements-frozen.txt - LICENSE - OPERATOR_RUNBOOK.md - ProxyGod-v1.0.0-windows-amd64.zip -dist/ - ProxyChainManager.exe # same binary (developer convenience) - ProxyChainManager.exe.sha256 -``` - -## Version numbering - -Resolved in order: - -1. `-Version` parameter to `release_build.ps1` -2. Exact git tag on current commit (`git describe --tags --exact-match`) -3. `git describe --tags --always --dirty` -4. `proxy_chain_manager.__version__` - -Tag releases with `v1.2.3` — CI **release.yml** runs automatically on `v*` tags. - -## Authenticode signing (optional) - -Set before building: - -```powershell -$env:SIGN_CERT_PATH = "C:\certs\proxygod.pfx" -$env:SIGN_CERT_PASSWORD = "your-password" # optional if pfx has no password -powershell -File .\scripts\release_build.ps1 -``` - -Requires **Windows SDK** (`signtool.exe` on PATH). Without a cert, the build completes unsigned (SmartScreen may warn on first run). - -## CI / GitHub Releases - -- **Every push/PR**: `.github/workflows/test.yml` — unit tests only -- **Tag `v*` or manual dispatch**: `.github/workflows/release.yml` — full release build + artifact upload + GitHub Release assets - -```bash -git tag v1.0.0 -git push origin v1.0.0 -``` - -## Before tagging (checklist) - -- [ ] `python -m unittest discover -s tests -v` passes locally -- [ ] CHANGELOG.md updated -- [ ] No secrets in `settings.json` / signup JSON committed -- [ ] `proxy_chain_manager/_bundled/gost.exe` will be fetched at build time (or pre-staged) -- [ ] Verify SHA256 after build: `Get-FileHash releases\v*\ProxyChainManager.exe -Algorithm SHA256` - -## Verify a release artifact - -```powershell -Get-FileHash -Algorithm SHA256 releases\v1.0.0\ProxyChainManager.exe -Get-Content releases\v1.0.0\ProxyChainManager.exe.sha256 -Get-Content releases\v1.0.0\RELEASE_MANIFEST.json | ConvertFrom-Json -``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 393f6b7..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,20 +0,0 @@ -# Roadmap - - -## Now - -- Confirm the project purpose in the README. -- Confirm build, run, or flash instructions on a clean machine. -- Classify license status and upstream provenance. -- Close the stewardship readiness checklist issue. - -## Next - -- Add project-specific tests or verification steps. -- Publish the first verified release only after provenance and security review. -- Replace placeholder wiki notes with project-specific architecture or hardware details. - -## Later - -- Add examples, screenshots, wiring diagrams, or demo media where useful. -- Decide whether duplicate or experimental branches should be archived. diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md deleted file mode 100644 index 2d56a0d..0000000 --- a/docs/SECURITY_REVIEW.md +++ /dev/null @@ -1,14 +0,0 @@ -# Security Review - - -## Required Checks - -- [ ] No credentials, tokens, cookies, API keys, private keys, or session files. -- [ ] No private user data, dumps, card data, logs, or captures that should not be stored. -- [ ] No copied dependency trees where package managers or SDK installers should be used instead. -- [ ] No unexplained binaries in source history. -- [ ] Risky behavior is documented and scoped to authorized lab use. - -## Release Gate - -A release is blocked until the checklist is complete or a maintainer explicitly records why the item does not apply. diff --git a/proxy_chain_manager/__init__.py b/proxy_chain_manager/__init__.py index 917bc75..0e53460 100644 --- a/proxy_chain_manager/__init__.py +++ b/proxy_chain_manager/__init__.py @@ -1,3 +1,3 @@ """Rotating proxy chain manager with a GOST backend.""" -__version__ = "2.0.0-mac" +__version__ = "2.0.1-mac" diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index db710c3..e201cb0 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -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() diff --git a/proxy_chain_manager/artifact_wipe.py b/proxy_chain_manager/artifact_wipe.py index f9f7347..d1c2c76 100644 --- a/proxy_chain_manager/artifact_wipe.py +++ b/proxy_chain_manager/artifact_wipe.py @@ -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")) diff --git a/proxy_chain_manager/config.py b/proxy_chain_manager/config.py index 154aefc..777d849 100644 --- a/proxy_chain_manager/config.py +++ b/proxy_chain_manager/config.py @@ -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): diff --git a/proxy_chain_manager/fingerprint.py b/proxy_chain_manager/fingerprint.py index 05ffcf7..9e16a6f 100644 --- a/proxy_chain_manager/fingerprint.py +++ b/proxy_chain_manager/fingerprint.py @@ -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] diff --git a/proxy_chain_manager/leak_audit.py b/proxy_chain_manager/leak_audit.py index 947bbdc..84c1194 100644 --- a/proxy_chain_manager/leak_audit.py +++ b/proxy_chain_manager/leak_audit.py @@ -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 diff --git a/proxy_chain_manager/mac_spoof.py b/proxy_chain_manager/mac_spoof.py index e6d9872..00a472c 100644 --- a/proxy_chain_manager/mac_spoof.py +++ b/proxy_chain_manager/mac_spoof.py @@ -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() diff --git a/proxy_chain_manager/macos_privileged.py b/proxy_chain_manager/macos_privileged.py new file mode 100644 index 0000000..c1fc098 --- /dev/null +++ b/proxy_chain_manager/macos_privileged.py @@ -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) diff --git a/proxy_chain_manager/privacy_lan.py b/proxy_chain_manager/privacy_lan.py index 734c908..ae81314 100644 --- a/proxy_chain_manager/privacy_lan.py +++ b/proxy_chain_manager/privacy_lan.py @@ -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] = [] diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index 3ff6fc4..b1915f9 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -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", diff --git a/proxy_chain_manager/sysproxy.py b/proxy_chain_manager/sysproxy.py index 0c525b5..a37e4da 100644 --- a/proxy_chain_manager/sysproxy.py +++ b/proxy_chain_manager/sysproxy.py @@ -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() != "" ] - 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: diff --git a/proxy_chain_manager/telemetry_kill.py b/proxy_chain_manager/telemetry_kill.py index 6ebe836..b040cb4 100644 --- a/proxy_chain_manager/telemetry_kill.py +++ b/proxy_chain_manager/telemetry_kill.py @@ -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] = [] diff --git a/proxy_chain_manager/webrtc_check.py b/proxy_chain_manager/webrtc_check.py index fe799d9..3cec2d8 100644 --- a/proxy_chain_manager/webrtc_check.py +++ b/proxy_chain_manager/webrtc_check.py @@ -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) diff --git a/proxy_chain_manager/windows_task.py b/proxy_chain_manager/windows_task.py index aafa3a8..510e593 100644 --- a/proxy_chain_manager/windows_task.py +++ b/proxy_chain_manager/windows_task.py @@ -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],