first commit

This commit is contained in:
drjones
2026-05-23 21:58:06 -07:00
commit 1f5e63ca1f
73 changed files with 13565 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.DS_Store
# Local build/runtime artifacts
.build/
build/
dist/
*.spec
*.log
ProxyChainManager.app/
# Local operator state
settings.json
gost
gost.exe
*.sha256

28
Bundle/ProxyGodInfo.plist Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ProxyGod</string>
<key>CFBundleIdentifier</key>
<string>local.proxy-god.mac</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Proxy God</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

84
CHANGELOG.md Normal file
View File

@@ -0,0 +1,84 @@
# Changelog
All meaningful changes to this repository should be recorded here.
## Unreleased — 2026-05-22 (Audit round 3)
### Security
- **Fail-closed leak detection**: `is_chain_leak()` now treats unknown direct
IP as a leak (was: pass-through). `service.py` retries direct-IP lookup
3× with 2 s back-off as a warm-up grace so transient network blips don't
cause permanent rotation.
### Reliability
- **Single asyncio loop in preflight**: removed per-call `asyncio.run()` from
the preflight worker thread; one loop is created, drained, and closed.
- **Close-window UX**: X button now prompts when chain or firewall is still
active (tray / full quit / cancel) instead of silently minimizing.
- **Admin relaunch**: settings persist before the elevated process spawns.
- **Persona/cookie key lookup**: precomputed `label→key` dicts; label copy
changes can no longer break the round-trip.
### Distribution
- **GOST bundled**: `scripts/prepare_bundled_gost.ps1` downloads and SHA-
verifies `gost.exe`, stages it under `proxy_chain_manager/_bundled/`, and
PyInstaller bundles it into the exe. `ensure_gost()` installs from the
bundle on first run (no internet required); network fetch is fallback only.
### Tests
- New suites: `test_config_round_trip`, `test_ban_tester`, `test_gost_util`,
`test_fail_closed`, `test_firewall_helpers`.
- Total: **47 → 82** passing.
### Held by design (audit §12)
- Signup extension stays on `<all_urls>` — custom signup URLs need it.
- `verify=False` on httpx probes — broken public-proxy TLS.
- DNS pass-through in kill-switch — GOST needs system DNS.
- Authenticode signing — pending Cert provisioning.
## Unreleased — 2026-05-21 (Audit remediation P0/P1/P2)
### Security (P0)
- **Secrets at rest**: Added `_mask()` helper in `config.py` to redact credentials from any
debug log output. Added `SETTINGS_SCHEMA_VERSION` and prominent plaintext-storage warning.
- **GOST integrity**: `gost_util.py` now verifies the downloaded release zip against a pinned
SHA256 (`GOST_RELEASE_ZIP_SHA256`) before extraction. Supply-chain swap raises `RuntimeError`.
- **Kill-switch recovery**: `firewall.py` exposes `emergency_disengage()`. `service.py`
registers it via `atexit` and `signal.SIGTERM`/`SIGINT` so a crash cannot leave outbound
traffic permanently blocked.
### Reliability (P0/P1)
- **Top-level exception handler**: `app.py` `main()` now catches unhandled exceptions, shows a
`tkinter.messagebox` error dialog, logs to file, and re-raises.
- **Log rotation**: `app.py` switches from bare `FileHandler` to `RotatingFileHandler`
(5 MB max, 3 backups).
- **Settings migration**: `config.py` gains `settings_version` field, `migrate()` function, and
safe corrupt-file backup (`settings.json.corrupt`) on parse failure.
- **Settings backup**: `save_settings()` copies `settings.json → settings.json.bak` before
overwriting.
- **Graceful shutdown**: `service.py` `stop()` calls `fw_emergency_disengage()` if the thread
does not exit within 15 s.
- **Thread safety**: `ChainService._settings` reads/writes guarded by `threading.Lock`.
- **Input / URL validation**: `sanitize_settings()` now validates `sources` and `ip_check_url`
against an RFC-1918 / link-local block-list; unsafe entries are removed with a warning.
### CI / Distribution (P0/P1/P2)
- **GitHub Actions CI** added: `.github/workflows/test.yml` runs `compileall` + `unittest
discover` on Python 3.10 / 3.11 / 3.12 on `windows-latest`.
- **LICENSE**: MIT license added to repo root.
- **requirements.txt**: All four runtime dependencies pinned to exact versions; `cryptography`
listed for optional future credential encryption (not yet wired).
- **`.gitignore`**: Added `settings.json`, `*.json` credential files, and screenshot noise.
- **`--version` flag**: `run.py` now supports `--version` / `-V`.
### Documentation (P1/P2)
- **`docs/OPERATOR_RUNBOOK.md`**: Emergency firewall disengage, proxy leak, empty pool, GOST
quarantine, and settings-restore instructions.
- **Bare `except: pass` cleanup**: Key silent-failure sites in `tray.py`, `dns_leak.py`, and
`gost_util.py` now log a `warning` or `debug` message instead of swallowing errors.
## 2026-05-20 - Gitea Stewardship Import
- Verified README and wiki coverage.
- Added standard stewardship documentation where missing.
- Established security, contribution, release, and provenance expectations.

20
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,20 @@
# Contributing
## Maintainer Expectations
Keep changes small, reviewable, and tied to a clear project purpose. Do not mix source changes with generated build output or dependency caches.
## Before Committing
- Run the relevant build, lint, or smoke test when the project provides one.
- Check that no credentials, `.env` files, tokens, private keys, captures, dumps, or personal data are staged.
- Keep firmware binaries, large archives, and generated artifacts out of Git unless the repo explicitly documents otherwise.
- Preserve upstream licenses and attribution for third-party code.
## Documentation
Update README and wiki pages when setup, hardware, architecture, environment variables, or release behavior changes.
## Safety
Only submit work intended for authorized environments. Project documentation should make scope and safe operation clearer, never weaker.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dr Jones
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

14
LICENSE_STATUS.md Normal file
View File

@@ -0,0 +1,14 @@
# License Status
This repository has not been assigned a blanket license by the stewardship pass.
## Current Rule
- Existing upstream licenses must be preserved.
- Third-party code must retain attribution and license files.
- Original private work remains all rights reserved until an explicit license is selected.
- Do not assume MIT, Apache, GPL, or public-domain status unless a license file in this repository says so.
## Next Step
Classify ownership and dependencies before publishing releases or accepting external contributions.

29
Makefile Normal file
View File

@@ -0,0 +1,29 @@
PYTHON := $(shell scripts/find_tk_python.sh)
VENV := .venv
PY := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
APP := Proxy God.app
.PHONY: setup run app test clean swift-build
setup:
@if [ -x "$(PY)" ] && ! "$(PY)" -c "import tkinter" >/dev/null 2>&1; then rm -rf "$(VENV)"; fi
$(PYTHON) -m venv $(VENV)
$(PIP) install --upgrade pip
$(PIP) install -r requirements.txt
run: setup
$(PY) run.py
app:
chmod +x scripts/run_proxy_god.sh
mkdir -p "$(APP)/Contents/MacOS" "$(APP)/Contents/Resources"
cp Bundle/ProxyGodInfo.plist "$(APP)/Contents/Info.plist"
cp scripts/app_launcher.sh "$(APP)/Contents/MacOS/ProxyGod"
chmod +x "$(APP)/Contents/MacOS/ProxyGod"
test: setup
$(PY) -m unittest discover -s tests -v
clean:
rm -rf "$(VENV)" "$(APP)" .build dist build

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ProxyGod</string>
<key>CFBundleIdentifier</key>
<string>local.proxy-god.mac</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Proxy God</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
exec "$ROOT/scripts/run_proxy_god.sh"

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# Proxy God for macOS
This workspace now runs the Python/CustomTkinter Proxy God dashboard from the reference repo, patched for macOS launch and GOST bootstrap.
## Run
```sh
make run
```
Or double-click:
```text
run_mac.command
```
To refresh the clickable app bundle:
```sh
make app
open "Proxy God.app"
```
The first run creates `.venv`, installs dependencies, downloads the correct macOS GOST binary, and starts the GUI.
## What Was Ported
- Neon Proxy God v2 dashboard layout, top status bar, chain visualization, progress strip, and tabs.
- Live tab with direct/exit IP, exit intel, chain map, and verbose logs.
- Chain Builder with pinned chains, manual exit parsing, per-hop testing, fixed final hop, and source management.
- Browser tab with hardened Firefox profile controls.
- Ban Tester, Signup Prep, Privacy, and Settings tabs.
- GOST-backed rotating multi-hop chain service.
- Proxifly/jsDelivr proxy source ingestion, validation, pool rotation, exit verification, HTTPS tunnel probe, leak checks, and health rotation.
- 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`.
## 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.

19
SECURITY.md Normal file
View File

@@ -0,0 +1,19 @@
# Security Policy
## Scope
This repository is maintained for authorized, lawful work only. Do not use code, firmware, payloads, scripts, or documentation from this project against systems, accounts, devices, networks, cards, readers, or services you do not own or do not have explicit permission to test.
## Reporting
Report security concerns privately to the maintainer. Do not open public issues containing live credentials, tokens, private captures, card data, target identifiers, exploit chains, or sensitive logs.
## Secrets And Data
- Do not commit `.env` files, API keys, Wi-Fi credentials, session cookies, private keys, dumps, captures, or personal data.
- Firmware binaries and captured artifacts must include provenance notes and SHA256 hashes before release.
- Generated dependency folders and build output belong outside Git unless there is a documented reason.
## Maintainer Rule
If a change increases misuse risk, narrows safety boundaries, or weakens provenance, it must be rejected or quarantined until documented.

318
docs/AUDIT_FINDINGS.md Normal file
View File

@@ -0,0 +1,318 @@
# 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 `<all_urls>`.
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 `<all_urls>` 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 18 hops; UI/config says 28 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-01E-06, E-11, E-22, E-25E-27, M-04M-07, M-11, B-04B-06, T-01T-05, T-09, U-01U-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 `<all_urls>` 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-02E-05** | Sticky exit / leak semantics need a product rule (rotate vs warn) |
| **E-07E-10** | Pool/pinned/exit edge cases need operator UX |
| **E-12E-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-16E-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-28E-30** | Third-party intel APIs / map geo / GOST blacklist rules |
| **M-01M-03, M-05, M-07M-14** | README alignment, encrypted vault export, ban history, IPv6 chain path, tray click-through |
| **U-01, U-02, U-04, U-05, U-07U-10** | Tray/minimize UX, admin-relaunch save state, cookie menu keys, password masking |
| **T-01T-14** | Larger test-coverage program |
| **B-03, B-06B-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 `<all_urls>` | 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)*

23
docs/MAINTENANCE.md Normal file
View File

@@ -0,0 +1,23 @@
# Maintenance
<!-- stewardship-standard: maintenance-v1 -->
## 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.

128
docs/OPERATOR_RUNBOOK.md Normal file
View File

@@ -0,0 +1,128 @@
# 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"
```

14
docs/PROJECT_HANDOFF.md Normal file
View File

@@ -0,0 +1,14 @@
# Project Handoff
<!-- stewardship-standard: project-handoff-v1 -->
## 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.

View File

@@ -0,0 +1,12 @@
# Provenance Checklist
<!-- stewardship-standard: provenance-checklist-v1 -->
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.

92
docs/RELEASE_PROCESS.md Normal file
View File

@@ -0,0 +1,92 @@
# 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
```

20
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,20 @@
# Roadmap
<!-- stewardship-standard: roadmap-v1 -->
## 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.

14
docs/SECURITY_REVIEW.md Normal file
View File

@@ -0,0 +1,14 @@
# Security Review
<!-- stewardship-standard: security-review-v1 -->
## 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

View File

@@ -0,0 +1,3 @@
"""Rotating proxy chain manager with a GOST backend."""
__version__ = "2.0.0-mac"

View File

@@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
main()

3074
proxy_chain_manager/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
"""One-button forensic artifact wipe.
Surfaces purged:
%TEMP% — per-user temp
%SystemRoot%\\Prefetch\\*.pf (Admin only) — file-launch history
%APPDATA%\\Microsoft\\Windows\\Recent\\* — recent files
%APPDATA%\\Microsoft\\Windows\\Recent\\AutomaticDestinations\\* — Jump Lists
%APPDATA%\\Microsoft\\Windows\\Recent\\CustomDestinations\\* — Jump Lists
• HKCU\\\\Explorer\\RunMRU — Win+R history
• HKCU\\\\Explorer\\TypedPaths — Explorer typed paths
• HKCU\\\\Explorer\\WordWheelQuery — Start / Explorer search history
• HKCU\\\\Explorer\\RecentDocs — recent docs MRU
• Clipboard — current contents
Counts are reported but specific filenames are never logged (this is the
opposite of what we want to leak). All deletes use ``ignore_errors=True``
because files in use by other apps are expected.
"""
from __future__ import annotations
import ctypes
import logging
import os
import shutil
import subprocess
import winreg
from dataclasses import dataclass, field
from pathlib import Path
from .firewall import is_admin
log = logging.getLogger(__name__)
_REG_MRU_KEYS = (
r"Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\WordWheelQuery",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs",
)
@dataclass
class WipeReport:
files_deleted: int = 0
bytes_freed: int = 0
folders_skipped: int = 0
registry_keys_cleared: int = 0
clipboard_cleared: bool = False
errors: list[str] = field(default_factory=list)
def summary(self) -> str:
mb = self.bytes_freed / (1024 * 1024)
return (
f"{self.files_deleted} files / {mb:.1f} MB freed, "
f"{self.registry_keys_cleared} MRU entries cleared, "
f"clipboard={'yes' if self.clipboard_cleared else 'no'}, "
f"errors={len(self.errors)}"
)
def _walk_size(path: Path) -> int:
total = 0
for p in path.rglob("*"):
try:
if p.is_file():
total += p.stat().st_size
except OSError:
pass
return total
def _purge_dir(path: Path, rep: WipeReport, keep_root: bool = True) -> None:
"""Delete contents of `path`. Preserves the directory itself when keep_root."""
if not path.exists():
return
try:
size_before = _walk_size(path)
except Exception:
size_before = 0
count = 0
for child in path.iterdir():
try:
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)
else:
child.unlink(missing_ok=True)
count += 1
except OSError:
rep.folders_skipped += 1
if not keep_root:
try:
path.rmdir()
except OSError:
pass
rep.files_deleted += count
try:
size_after = _walk_size(path)
except Exception:
size_after = 0
rep.bytes_freed += max(0, size_before - size_after)
def _clear_mru_key(path: str, rep: WipeReport) -> None:
"""Remove every value under a Run/Typed/Search MRU key."""
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_ALL_ACCESS
) as key:
count = 0
try:
while True:
name, _, _ = winreg.EnumValue(key, 0)
try:
winreg.DeleteValue(key, name)
count += 1
except OSError:
break
except OSError:
pass
# Recurse into subkeys (e.g. RecentDocs/.png)
sub_count = 0
try:
while True:
sub_count += 1
sub_name = winreg.EnumKey(key, 0)
try:
winreg.DeleteKey(key, sub_name)
except OSError:
break
if sub_count > 200: # safety cap
break
except OSError:
pass
if count or sub_count:
rep.registry_keys_cleared += 1
except OSError:
pass
def _clear_clipboard(rep: WipeReport) -> None:
try:
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
if user32.OpenClipboard(None):
try:
user32.EmptyClipboard()
rep.clipboard_cleared = True
finally:
user32.CloseClipboard()
except Exception as e:
rep.errors.append(f"clipboard: {e}")
def wipe_artifacts(include_prefetch: bool = True) -> WipeReport:
rep = WipeReport()
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"))
# %TEMP%
if temp and temp.exists():
_purge_dir(temp, rep, keep_root=True)
# Prefetch (Admin)
if include_prefetch and is_admin():
pf = sysroot / "Prefetch"
if pf.exists():
count = 0
for f in pf.glob("*.pf"):
try:
sz = f.stat().st_size
f.unlink(missing_ok=True)
count += 1
rep.bytes_freed += sz
except OSError as e:
rep.errors.append(f"prefetch: {e}")
rep.files_deleted += count
# Recent / Jump Lists
if appdata:
recent = appdata / "Microsoft" / "Windows" / "Recent"
if recent.exists():
_purge_dir(recent / "AutomaticDestinations", rep, keep_root=True)
_purge_dir(recent / "CustomDestinations", rep, keep_root=True)
_purge_dir(recent, rep, keep_root=True)
# MRU registry keys
for path in _REG_MRU_KEYS:
_clear_mru_key(path, rep)
# Clipboard
_clear_clipboard(rep)
# Trigger Explorer's "Clear recent items" via Shell API (covers Win10/11
# Quick Access pinned ↔ recent lists not covered by raw file delete).
try:
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
# SHCNE_ASSOCCHANGED tells Explorer to refresh its caches.
shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
except Exception:
pass
# Best-effort Defender quick-scan history flush — non-fatal.
try:
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"Clear-RecycleBin -Force -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
return rep

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
import concurrent.futures
import socket
from dataclasses import dataclass
from typing import Iterable
import httpx
# ── Site lists ────────────────────────────────────────────────────────────────
SITES_SOCIAL: tuple[tuple[str, str], ...] = (
("Google", "https://www.google.com/generate_204"),
("YouTube", "https://www.youtube.com/"),
("Facebook", "https://www.facebook.com/"),
("Instagram", "https://www.instagram.com/"),
("X / Twitter", "https://x.com/"),
("Reddit", "https://www.reddit.com/"),
("TikTok", "https://www.tiktok.com/"),
("Wikipedia", "https://www.wikipedia.org/"),
("DuckDuckGo", "https://duckduckgo.com/"),
("BBC", "https://www.bbc.com/"),
("GitHub", "https://github.com/"),
)
SITES_SHOPPING: tuple[tuple[str, str], ...] = (
("Amazon", "https://www.amazon.com/"),
("eBay", "https://www.ebay.com/"),
("Walmart", "https://www.walmart.com/"),
("Etsy", "https://www.etsy.com/"),
("AliExpress", "https://www.aliexpress.com/"),
("Best Buy", "https://www.bestbuy.com/"),
("Target", "https://www.target.com/"),
("Newegg", "https://www.newegg.com/"),
)
SITES_CRYPTO: tuple[tuple[str, str], ...] = (
("Coinbase", "https://www.coinbase.com/"),
("Binance", "https://www.binance.com/"),
("Kraken", "https://www.kraken.com/"),
("Bybit", "https://www.bybit.com/"),
("OKX", "https://www.okx.com/"),
("KuCoin", "https://www.kucoin.com/"),
("Bitfinex", "https://www.bitfinex.com/"),
("Gemini", "https://www.gemini.com/"),
)
SITES_DNS: tuple[tuple[str, str], ...] = (
("Cloudflare DNS (1.1.1.1)", "https://1.1.1.1/"),
("Google DNS (8.8.8.8)", "https://dns.google/"),
("Quad9 DNS (9.9.9.9)", "https://quad9.net/"),
("AdGuard DNS", "https://adguard-dns.io/"),
("NextDNS", "https://nextdns.io/"),
("Mullvad DNS", "https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/"),
("OpenDNS", "https://www.opendns.com/"),
)
# Combined default: all categories
POPULAR_SITES: tuple[tuple[str, str], ...] = (
SITES_SOCIAL + SITES_SHOPPING + SITES_CRYPTO + SITES_DNS
)
SITE_CATEGORIES: dict[str, tuple[tuple[str, str], ...]] = {
"Social / General": SITES_SOCIAL,
"Shopping": SITES_SHOPPING,
"Crypto Exchanges": SITES_CRYPTO,
"DNS Providers": SITES_DNS,
"All": POPULAR_SITES,
}
_BANNED_STATUS_CODES = {401, 403, 407, 418, 429, 451}
_BANNED_TEXT_HINTS = (
"access denied",
"forbidden",
"temporarily blocked",
"request blocked",
"unusual traffic",
"challenge required",
"bot detected",
"suspicious activity",
"geo-blocked",
"not available in your region",
"cloudflare ray",
"ddos protection",
"attention required",
"complete the captcha",
"captcha required",
"g-recaptcha",
"hcaptcha",
)
@dataclass(frozen=True)
class BanTestResult:
site: str
url: str
status: str # ok | banned | error
code: int | None
detail: str
category: str = ""
def _looks_banned_body(body: str) -> bool:
t = (body or "").lower()
return any(h in t for h in _BANNED_TEXT_HINTS)
def _test_one(
proxy_url: str,
site: str,
url: str,
timeout_seconds: float,
category: str = "",
) -> BanTestResult:
timeout = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
try:
with httpx.Client(
proxy=proxy_url,
timeout=timeout,
verify=False,
follow_redirects=True,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
) as c:
r = c.get(url)
body = r.text[:2400] if r.text else ""
if r.status_code in _BANNED_STATUS_CODES or _looks_banned_body(body):
return BanTestResult(
site, url, "banned", r.status_code,
f"HTTP {r.status_code}", category,
)
if 200 <= r.status_code < 400:
return BanTestResult(site, url, "ok", r.status_code, f"HTTP {r.status_code}", category)
return BanTestResult(site, url, "error", r.status_code, f"HTTP {r.status_code}", category)
except Exception as e:
short = str(e)[:80]
return BanTestResult(site, url, "error", None, f"{type(e).__name__}: {short}", category)
def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None:
"""Resolve a hostname using system DNS (does not go through proxy — exposes leak)."""
_prev = socket.getdefaulttimeout()
try:
socket.setdefaulttimeout(timeout)
info = socket.getaddrinfo(hostname, None)
for entry in info:
addr = entry[4][0]
if addr:
return addr
except Exception:
pass
finally:
socket.setdefaulttimeout(_prev)
return None
# NOTE: removed legacy ``check_dns_leak`` / ``DnsLeakResult`` — they relied on
# the ``x-real-ip`` HTTP response header which proxy targets do not set, so
# results were meaningless. Use :func:`proxy_chain_manager.dns_leak.run_dns_leak_test`
# instead.
def run_ban_tests(
proxy_url: str,
timeout_seconds: float = 12.0,
sites: Iterable[tuple[str, str]] | None = None,
categories: Iterable[str] | None = None,
max_workers: int = 8,
) -> list[BanTestResult]:
"""
Run ban tests in parallel across requested site categories.
``categories`` accepts names from SITE_CATEGORIES (e.g. "Shopping", "Crypto Exchanges").
Defaults to all categories when neither ``sites`` nor ``categories`` is given.
"""
if sites is not None:
work = [(s, u, "") for s, u in sites]
elif categories is not None:
seen_urls: set[str] = set()
work = []
for cat in categories:
for s, u in SITE_CATEGORIES.get(cat, ()):
if u not in seen_urls:
seen_urls.add(u)
work.append((s, u, cat))
else:
work = [(s, u, cat) for cat, pairs in SITE_CATEGORIES.items() if cat != "All" for s, u in pairs]
out: list[BanTestResult] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {
pool.submit(_test_one, proxy_url, site, url, timeout_seconds, cat): (site, url, cat)
for site, url, cat in work
}
for fut in concurrent.futures.as_completed(futures):
try:
out.append(fut.result())
except Exception:
site, url, cat = futures[fut]
out.append(BanTestResult(site, url, "error", None, "internal error", cat))
# Sort: category then site name
out.sort(key=lambda r: (r.category, r.site))
return out
def test_single_site(
proxy_url: str,
site: str,
url: str,
timeout_seconds: float = 10.0,
) -> BanTestResult:
"""Probe one URL through the proxy. Used by pre-flight checks."""
return _test_one(proxy_url, site, url, timeout_seconds)

View File

@@ -0,0 +1,327 @@
"""Browser identity personas + cookie policies.
The "lock everything down" approach (Firefox RFP + WebRTC off + cookies blocked)
keeps you safe from fingerprinting but makes the machine *look weird* — sites
flag you as suspicious and ban / captcha you. Worse: very few users use that
config, so RFP-on is itself a fingerprint.
Two strategies are exposed:
• **Blend-in personas** — mimic the most common Windows-Chrome-US-en setup.
JS / cookies / WebGL all work; everything looks normal; only WebRTC is
blocked (because that leaks the real IP behind the proxy). The proxy
chain itself does the actual hiding.
• **Hardened** — full RFP / blocked WebRTC / strict tracking / FPI. Stand-out
but maximum block. Same behavior as the original profile.
Cookie modes are independent of persona. Each mode expresses a complete
policy: which cookies to accept, lifetime, whether to wipe on close, and
whether to partition 3rd-party storage.
"""
from __future__ import annotations
from dataclasses import dataclass
# ── Personas ──────────────────────────────────────────────────────────────────
PERSONAS: list[str] = [
"blend_windows_chrome",
"blend_windows_chrome_laptop",
"blend_windows_firefox",
"blend_windows_edge",
"blend_mac_safari",
"blend_mac_chrome",
"blend_linux_firefox",
"hardened",
"custom",
]
PERSONA_LABELS: dict[str, str] = {
"blend_windows_chrome": "Blend — Win10 + Chrome 124 (most common, US-en)",
"blend_windows_chrome_laptop": "Blend — Win11 + Chrome 124 laptop 1366×768",
"blend_windows_firefox": "Blend — Win10 + Firefox 128 (US-en)",
"blend_windows_edge": "Blend — Win11 + Edge 124 (corporate look)",
"blend_mac_safari": "Blend — macOS 14 + Safari 17 (US-en)",
"blend_mac_chrome": "Blend — macOS 14 + Chrome 124 (US-en)",
"blend_linux_firefox": "Blend — Ubuntu + Firefox 128 (en-US)",
"hardened": "Hardened — RFP + FPI + block all (stands out)",
"custom": "Custom — use individual hardening toggles",
}
@dataclass(frozen=True)
class Persona:
"""A user-agent / locale / timezone / screen tuple that mimics a real,
common configuration."""
key: str
user_agent: str
accept_language: str
timezone: str
screen_w: int
screen_h: int
platform: str
locale: str
PERSONA_DATA: dict[str, Persona] = {
"blend_windows_chrome": Persona(
key="blend_windows_chrome",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
accept_language="en-US,en;q=0.9",
timezone="America/New_York",
screen_w=1920, screen_h=1080,
platform="Win32",
locale="en-US",
),
"blend_windows_chrome_laptop": Persona(
key="blend_windows_chrome_laptop",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
accept_language="en-US,en;q=0.9",
timezone="America/Chicago",
screen_w=1366, screen_h=768,
platform="Win32",
locale="en-US",
),
"blend_windows_firefox": Persona(
key="blend_windows_firefox",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
),
accept_language="en-US,en;q=0.5",
timezone="America/New_York",
screen_w=1920, screen_h=1080,
platform="Win32",
locale="en-US",
),
"blend_windows_edge": Persona(
key="blend_windows_edge",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
),
accept_language="en-US,en;q=0.9",
timezone="America/Chicago",
screen_w=1920, screen_h=1080,
platform="Win32",
locale="en-US",
),
"blend_mac_safari": Persona(
key="blend_mac_safari",
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15"
),
accept_language="en-US,en;q=0.9",
timezone="America/Los_Angeles",
screen_w=1680, screen_h=1050,
platform="MacIntel",
locale="en-US",
),
"blend_mac_chrome": Persona(
key="blend_mac_chrome",
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
accept_language="en-US,en;q=0.9",
timezone="America/Los_Angeles",
screen_w=1440, screen_h=900,
platform="MacIntel",
locale="en-US",
),
"blend_linux_firefox": Persona(
key="blend_linux_firefox",
user_agent=(
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
),
accept_language="en-US,en;q=0.5",
timezone="America/New_York",
screen_w=1920, screen_h=1080,
platform="Linux x86_64",
locale="en-US",
),
}
# ── Cookie policies ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class CookiePolicy:
"""
Complete Firefox cookie configuration expressed as a single composable unit.
behavior : network.cookie.cookieBehavior
0 = accept all
1 = block 3rd-party
2 = block ALL cookies
3 = block 3rd-party from unvisited sites
4 = block cross-site tracking (ETP Strict)
5 = block cross-site + social media trackers
lifetime : network.cookie.lifetimePolicy
0 = normal (respect Set-Cookie expires)
2 = session-only (all cookies expire on close)
partition : Total Cookie Protection — partition 3rd-party storage
by the top-level site (network.cookie.cookieBehavior.optInPartitioning)
clear_cookies : privacy.clearOnShutdown.cookies
clear_cache : privacy.clearOnShutdown.cache
clear_history : privacy.clearOnShutdown.history
clear_ls : privacy.clearOnShutdown.localStorage + indexedDB
clear_formdata : privacy.clearOnShutdown.formdata
sanitize : privacy.sanitize.sanitizeOnShutdown (master switch)
"""
behavior: int = 1
lifetime: int = 0
partition: bool = False
sanitize: bool = False
clear_cookies: bool = False
clear_cache: bool = False
clear_history: bool = False
clear_ls: bool = False
clear_formdata: bool = False
# Every cookie mode is a complete CookiePolicy — no guesswork in profile.py.
COOKIE_POLICIES: dict[str, CookiePolicy] = {
# ── Permissive ──────────────────────────────────────────────────────────
"accept_all": CookiePolicy(
behavior=0, lifetime=0,
# No clearing, no blocking — maximum site compatibility.
),
"accept_all_session": CookiePolicy(
behavior=0, lifetime=2,
sanitize=True, clear_cookies=True, clear_ls=True,
# Accept all cookies while browsing but wipe them when the
# browser closes. Good for signup / form-filling sessions.
),
# ── Standard 3rd-party blocking ────────────────────────────────────────
"block_third_party": CookiePolicy(
behavior=1, lifetime=0,
# Block 3rd-party, accept 1st-party with normal lifetime.
# Recommended baseline — most sites work fine.
),
"block_third_party_session": CookiePolicy(
behavior=1, lifetime=2,
sanitize=True, clear_cookies=True, clear_cache=True, clear_ls=True,
# Block 3rd-party AND treat all accepted cookies as session-only.
# Nothing persists. Use for anonymous sessions.
),
# ── Tracker-focused blocking ────────────────────────────────────────────
"block_third_party_trackers": CookiePolicy(
behavior=4, lifetime=0,
# Firefox ETP Strict mode: only blocks known trackers, not ALL
# 3rd-party cookies. Best blend-in option — same as Firefox default.
),
"block_social_trackers": CookiePolicy(
behavior=5, lifetime=0,
# Block cross-site cookies AND social-media tracking cookies
# (Facebook, Twitter pixels, etc.). Stricter than ETP Strict but
# still allows most logins.
),
"block_unvisited": CookiePolicy(
behavior=3, lifetime=0,
# Block 3rd-party from sites the user has never visited. Very light
# — almost invisible to sites; good when you need max compatibility.
),
# ── Partitioned storage (Total Cookie Protection) ───────────────────────
"partitioned_tcp": CookiePolicy(
behavior=1, lifetime=0, partition=True,
# Block 3rd-party AND enable Total Cookie Protection: each site
# gets its own isolated cookie jar so cross-site tracking via
# cookie sync is impossible. Recommended for blend-in + privacy.
),
"partitioned_session": CookiePolicy(
behavior=1, lifetime=2, partition=True,
sanitize=True, clear_cookies=True, clear_cache=True,
clear_ls=True, clear_formdata=True,
# Partitioned + session-only + full wipe on close. Leaves no
# persistent state on disk after the browser exits.
),
# ── Maximum wipe ───────────────────────────────────────────────────────
"session_only": CookiePolicy(
behavior=0, lifetime=2,
sanitize=True, clear_cookies=True,
# All cookies accepted but expire when the browser closes.
# Note: localStorage is NOT cleared here — only cookies.
),
"ghost_mode": CookiePolicy(
behavior=1, lifetime=2,
sanitize=True, clear_cookies=True, clear_cache=True,
clear_history=True, clear_ls=True, clear_formdata=True,
partition=True,
# Maximum ephemeral session: block 3rd-party, TCP partitioning,
# session-only lifetime, full wipe of cookies + cache + history +
# localStorage/IndexedDB + formdata on every close. Zero disk trace.
),
"block_all": CookiePolicy(
behavior=2, lifetime=0,
# Block ALL cookies. Most login-dependent sites will break.
# Only useful for read-only scraping sessions.
),
}
COOKIE_MODES: list[str] = list(COOKIE_POLICIES)
COOKIE_LABELS: dict[str, str] = {
"accept_all": "Accept all — maximum compatibility (cookies persist)",
"accept_all_session": "Accept all, session only — wipe cookies on close",
"block_third_party": "Block 3rd-party — recommended baseline (persists 1st-party)",
"block_third_party_session": "Block 3rd-party + session — nothing persists",
"block_third_party_trackers":"Block trackers only — ETP Strict (Firefox default-strict)",
"block_social_trackers": "Block social + cross-site trackers (Facebook pixel, etc.)",
"block_unvisited": "Block 3rd-party from unvisited sites — lightest option",
"partitioned_tcp": "Partitioned (TCP) — each site gets isolated 3rd-party jar",
"partitioned_session": "Partitioned + session — isolated jars, full wipe on close",
"session_only": "Session only — all cookies expire on close (no partition)",
"ghost_mode": "Ghost mode — partitioned + session + full disk wipe on close",
"block_all": "Block ALL cookies — breaks most logins",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def get_cookie_policy(mode: str) -> CookiePolicy:
return COOKIE_POLICIES.get(mode, COOKIE_POLICIES["block_third_party"])
# Legacy shims kept for code that still calls these directly.
def cookie_behavior_value(mode: str) -> int:
return get_cookie_policy(mode).behavior
def cookie_session_only(mode: str) -> bool:
return get_cookie_policy(mode).lifetime == 2
def get_persona(key: str) -> Persona | None:
return PERSONA_DATA.get(key)
def is_blend_persona(key: str) -> bool:
return key.startswith("blend_")

View File

@@ -0,0 +1,402 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
import time
import winreg
from dataclasses import dataclass
from pathlib import Path
from .browser_profile import FirefoxHardening, ensure_firefox_profile, persona_env
from .paths import app_data_dir
from .signup_prep import SignupDraft, install_signup_extension
log = logging.getLogger(__name__)
# CREATE_NO_WINDOW suppresses the console; DETACHED_PROCESS fully separates
# the child from Python's job object so Firefox lives past Python's exit.
_CREATE_FLAGS = 0 if sys.platform != "win32" else (
getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
)
@dataclass
class BrowserConfig:
firefox_path: str = ""
profile_dir: str = ""
clear_on_close: bool = True
disposable_profile: bool = False
kill_on_chain_drop: bool = True
auto_relaunch: bool = False
lock_managed_profile: bool = True
force_proxy: bool = True
disable_webrtc: bool = True
resist_fingerprinting: bool = True
disable_telemetry: bool = True
first_party_isolation: bool = True
strict_tracking_protection: bool = True
timezone_utc: bool = True
persona: str = "blend_windows_chrome"
cookie_mode: str = "block_third_party"
def _find_firefox_via_registry() -> str:
"""Look up Firefox install path in the Windows registry."""
hives = [
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Mozilla\Mozilla Firefox"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Mozilla\Mozilla Firefox"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Mozilla\Mozilla Firefox"),
]
for hive, key_path in hives:
try:
with winreg.OpenKey(hive, key_path) as root:
version = winreg.QueryValue(root, None)
for sub in (f"{version}\\Main", "bin"):
try:
with winreg.OpenKey(root, sub) as k:
path = winreg.QueryValueEx(k, "PathToExe")[0]
if path and Path(path).is_file():
return str(path)
except OSError:
continue
except OSError:
continue
# Also try the "Uninstall" key
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for root_path in (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
):
try:
with winreg.OpenKey(hive, root_path) as base:
i = 0
while True:
try:
sub = winreg.EnumKey(base, i)
i += 1
if "firefox" not in sub.lower():
continue
with winreg.OpenKey(base, sub) as k:
loc = winreg.QueryValueEx(k, "InstallLocation")[0]
candidate = Path(loc) / "firefox.exe"
if candidate.is_file():
return str(candidate)
except OSError:
break
except OSError:
continue
return ""
def default_firefox_path() -> str:
"""Find Firefox.exe — registry first, then well-known paths."""
if sys.platform == "darwin":
candidates = [
Path("/Applications/Firefox.app/Contents/MacOS/firefox"),
Path.home() / "Applications" / "Firefox.app" / "Contents" / "MacOS" / "firefox",
]
which = shutil.which("firefox")
if which:
candidates.insert(0, Path(which))
for c in candidates:
if c.is_file():
return str(c)
return ""
# 1) Registry
reg = _find_firefox_via_registry()
if reg:
return reg
# 2) Standard install locations
candidates: list[Path] = [
Path(r"C:\Program Files\Mozilla Firefox\firefox.exe"),
Path(r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"),
Path(os.path.expandvars(r"%LOCALAPPDATA%\Mozilla Firefox\firefox.exe")),
Path(os.path.expandvars(r"%APPDATA%\Mozilla Firefox\firefox.exe")),
]
# 3) Scoop / winget / portable in PATH
which = shutil.which("firefox") or shutil.which("firefox.exe")
if which:
candidates.insert(0, Path(which))
for c in candidates:
if c.is_file():
return str(c)
return ""
def default_profile_dir() -> str:
return str(app_data_dir() / "browser_profiles" / "firefox_hardened")
def _firefox_is_running_on_system() -> bool:
"""Check if any firefox.exe process is alive (tasklist, no admin needed)."""
if sys.platform == "darwin":
try:
r = subprocess.run(["pgrep", "-x", "firefox"], capture_output=True, text=True, timeout=6)
return r.returncode == 0 and bool((r.stdout or "").strip())
except Exception:
return False
try:
r = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq firefox.exe", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=6,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
return "firefox.exe" in (r.stdout or "").lower()
except Exception:
return False
def _has_descendants(root_pid: int) -> bool:
"""True iff *root_pid* or any descendant process is currently alive.
Walks the process tree from root_pid using WMIC ProcessId/ParentProcessId
(no admin required). Falls back to checking the root PID's existence via
tasklist if WMIC is missing on the host.
"""
if not root_pid:
return False
if sys.platform == "darwin":
try:
r = subprocess.run(["ps", "-p", str(root_pid)], capture_output=True, text=True, timeout=6)
return str(root_pid) in (r.stdout or "")
except Exception:
return False
# Fast path: is the root PID itself still alive?
try:
r = subprocess.run(
["tasklist", "/FI", f"PID eq {root_pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=6,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
if str(root_pid) in (r.stdout or ""):
return True
except Exception: # noqa: BLE001
pass
# Walk descendants via WMIC. Builds {parent: [child, ...]} then BFS.
try:
w = subprocess.run(
["wmic", "process", "get", "ProcessId,ParentProcessId", "/FORMAT:CSV"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
children: dict[int, list[int]] = {}
for line in (w.stdout or "").splitlines():
parts = [p.strip() for p in line.split(",")]
# CSV header: Node,ParentProcessId,ProcessId
if len(parts) < 3 or not parts[1].isdigit() or not parts[2].isdigit():
continue
ppid, pid = int(parts[1]), int(parts[2])
children.setdefault(ppid, []).append(pid)
stack = [root_pid]
seen = {root_pid}
while stack:
cur = stack.pop()
kids = children.get(cur, [])
for k in kids:
if k in seen:
continue
seen.add(k)
# Any live descendant = our session is still alive.
return True
return False
except Exception: # noqa: BLE001
# WMIC missing (newer Windows) — be conservative and return False
# rather than the old "any firefox.exe = mine" heuristic.
return False
class BrowserSession:
def __init__(self) -> None:
self._proc: subprocess.Popen[str] | None = None
self._profile_path: Path | None = None
self._launched_at: float = 0.0
def is_running(self) -> bool:
if self._proc is None:
return False
# Firefox's initial launcher process exits quickly (code 0) while
# browser child processes carry on. Poll the parent process first.
if self._proc.poll() is None:
return True
# Parent has exited — check whether any process in our spawned tree
# (the original PID's children) is still alive. Using a PID-scoped
# WMIC query avoids the old failure mode of treating any unrelated
# firefox.exe on the machine as ours.
launched_pid = self._proc.pid
return _has_descendants(launched_pid)
def pid(self) -> int | None:
if self._proc is None:
return None
return self._proc.pid
def launch(
self,
cfg: BrowserConfig,
proxy_host: str,
proxy_port: int,
start_url: str = "",
signup_draft: SignupDraft | None = None,
) -> tuple[bool, str]:
raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'")
if not raw_exe:
return (
False,
"Firefox not found. Install Firefox then set the path in the Browser tab "
"(or browse to it with the Browse button).",
)
exe = Path(raw_exe)
if not exe.is_file():
return False, (
f"Firefox executable not found: {raw_exe} "
"Use the Browse button on the Browser tab to locate firefox.exe."
)
raw_profile = (cfg.profile_dir or default_profile_dir()).strip().strip('"').strip("'")
profile_dir = Path(raw_profile)
hard = FirefoxHardening(
force_proxy=cfg.force_proxy,
disable_webrtc=cfg.disable_webrtc,
resist_fingerprinting=cfg.resist_fingerprinting,
disable_telemetry=cfg.disable_telemetry,
first_party_isolation=cfg.first_party_isolation,
strict_tracking_protection=cfg.strict_tracking_protection,
clear_on_shutdown=cfg.clear_on_close,
timezone_utc=cfg.timezone_utc,
persona_key=cfg.persona,
cookie_mode=cfg.cookie_mode,
)
try:
if cfg.lock_managed_profile:
ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard)
if signup_draft is not None:
install_signup_extension(profile_dir, signup_draft)
except Exception as e:
return False, f"Profile setup failed: {e!s}"
if self.is_running():
self.stop()
if cfg.lock_managed_profile:
args = [str(exe), "-no-remote", "-profile", str(profile_dir)]
else:
args = [str(exe)]
url = (start_url or "").strip()
if url:
args.append(url)
env = os.environ.copy()
env.update(persona_env(hard))
try:
self._proc = subprocess.Popen(
args,
# DETACHED_PROCESS ensures Firefox outlives Python and is not
# killed when Python's job object exits. CREATE_NO_WINDOW
# suppresses any console window. CREATE_NEW_PROCESS_GROUP
# isolates Ctrl-C handling.
creationflags=_CREATE_FLAGS,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
env=env,
)
except Exception as e:
self._proc = None
return False, f"Launch failed: {e!s}"
self._launched_at = time.monotonic()
# Firefox's multi-process launcher exits (code 0) within ~0.5 s while
# the actual browser window loads in child processes. We therefore do
# NOT treat a quick exit of the parent as an error — we just verify
# that *some* firefox.exe appears in the process list within 3 s.
deadline = time.monotonic() + 3.0
found = False
while time.monotonic() < deadline:
time.sleep(0.3)
rc = self._proc.poll()
# If it already exited with an error code, report it.
if rc is not None and rc not in (0, None):
self._proc = None
return False, f"Firefox exited with error code {rc}."
if _firefox_is_running_on_system():
found = True
break
if not found:
# One last check — maybe the profile write was the only action needed
# and Firefox opened fast; accept success if parent is still alive.
if self._proc.poll() is None:
found = True
if not found:
self._proc = None
return False, (
"Firefox did not appear in the process list after 3 s. "
"Check that the path is correct and Firefox is not already blocked by another -profile lock."
)
self._profile_path = profile_dir if cfg.lock_managed_profile else None
pid = self._proc.pid
log.info("Launched hardened Firefox pid=%s profile=%s", pid, profile_dir)
mode = "hardened profile" if cfg.lock_managed_profile else "unlocked mode"
return True, f"Firefox started ({mode}, pid {pid})."
def stop(self, dispose: bool = False) -> tuple[bool, str]:
# Capture our PID before clearing the handle
launched_pid: int | None = self._proc.pid if self._proc else None
# Terminate the Popen handle if still alive
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
self._proc.wait(timeout=8)
except Exception:
try:
self._proc.kill()
except Exception:
pass
finally:
self._proc = None
# Kill only the process tree we spawned (handles the detached-child
# case where the launcher process already exited naturally).
# /T kills the entire child tree; /PID scopes to our PID only.
if launched_pid is not None:
if sys.platform == "darwin":
try:
subprocess.run(["pkill", "-P", str(launched_pid)], capture_output=True, timeout=10)
except Exception:
pass
else:
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(launched_pid)],
capture_output=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
except Exception:
pass
self._proc = None
if dispose and self._profile_path and self._profile_path.exists():
try:
shutil.rmtree(self._profile_path, ignore_errors=True)
except Exception:
pass
self._launched_at = 0.0
return True, "Browser stopped."

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from .browser_identity import (
CookiePolicy,
Persona,
cookie_behavior_value,
cookie_session_only,
get_cookie_policy,
get_persona,
is_blend_persona,
)
@dataclass
class FirefoxHardening:
# Proxy & network always-on
force_proxy: bool = True
disable_webrtc: bool = True # always strongly recommended; leaks real IP
# Hardened block mode — overrides persona spoofing
resist_fingerprinting: bool = True
disable_telemetry: bool = True
first_party_isolation: bool = True
strict_tracking_protection: bool = True
clear_on_shutdown: bool = True
timezone_utc: bool = True
# Persona / cookie selectors (added in "spoof when possible" mode)
persona_key: str = "custom" # see browser_identity.PERSONAS
cookie_mode: str = "block_third_party" # see browser_identity.COOKIE_MODES
# Optional Firefox-managed user-agent override even outside of a persona
user_agent_override: str = ""
# Filled in by ``apply_persona`` when persona_key starts with "blend_".
persona: Persona | None = field(default=None, repr=False, compare=False)
def apply_persona(hard: FirefoxHardening) -> FirefoxHardening:
"""Mutate hardening flags consistent with the persona choice.
Blend-in personas relax the "stand out" toggles (RFP, FPI, strict TP) but
keep the IP-leakers off (WebRTC, geolocation, etc.). Cookie mode is
honored. The result is a profile that looks like a normal browser.
"""
if hard.persona_key == "hardened":
hard.resist_fingerprinting = True
hard.first_party_isolation = True
hard.strict_tracking_protection = True
hard.timezone_utc = True
hard.persona = None
return hard
if is_blend_persona(hard.persona_key):
hard.resist_fingerprinting = False # RFP itself is a fingerprint
hard.first_party_isolation = False # breaks logins on many sites
hard.strict_tracking_protection = False # Firefox default ETP is enough
hard.timezone_utc = False
hard.persona = get_persona(hard.persona_key)
if hard.persona and not hard.user_agent_override:
hard.user_agent_override = hard.persona.user_agent
return hard
# custom — honor the individual flags as-is.
hard.persona = None
return hard
def _bool(v: bool) -> str:
return "true" if v else "false"
def build_user_js(
proxy_host: str,
proxy_port: int,
hard: FirefoxHardening,
) -> str:
hard = apply_persona(hard)
lines: list[str] = [
"// Managed by Proxy God. Changes are overwritten on next launch.",
# Always-on baseline (no UX cost)
'user_pref("app.normandy.enabled", false);',
'user_pref("app.shield.optoutstudies.enabled", false);',
'user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false);',
'user_pref("browser.newtabpage.activity-stream.telemetry", false);',
'user_pref("browser.ping-centre.telemetry", false);',
'user_pref("toolkit.telemetry.archive.enabled", false);',
'user_pref("toolkit.telemetry.bhrPing.enabled", false);',
'user_pref("toolkit.telemetry.enabled", false);',
'user_pref("toolkit.telemetry.firstShutdownPing.enabled", false);',
'user_pref("toolkit.telemetry.hybridContent.enabled", false);',
'user_pref("toolkit.telemetry.newProfilePing.enabled", false);',
'user_pref("toolkit.telemetry.shutdownPingSender.enabled", false);',
'user_pref("toolkit.telemetry.unified", false);',
'user_pref("datareporting.healthreport.uploadEnabled", false);',
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
'user_pref("network.trr.mode", 5);',
'user_pref("network.captive-portal-service.enabled", false);',
'user_pref("geo.enabled", false);',
'user_pref("media.navigator.enabled", false);',
'user_pref("dom.battery.enabled", false);',
'user_pref("dom.gamepad.enabled", false);',
'user_pref("dom.netinfo.enabled", false);',
# WebGL allowed by default (blocking it makes you stand out massively)
'user_pref("webgl.enable-debug-renderer-info", false);',
]
if hard.force_proxy:
lines.extend(
[
'user_pref("network.proxy.type", 1);',
'user_pref("network.proxy.share_proxy_settings", true);',
f'user_pref("network.proxy.http", "{proxy_host}");',
f'user_pref("network.proxy.http_port", {int(proxy_port)});',
f'user_pref("network.proxy.ssl", "{proxy_host}");',
f'user_pref("network.proxy.ssl_port", {int(proxy_port)});',
'user_pref("network.proxy.ftp", "");',
'user_pref("network.proxy.ftp_port", 0);',
'user_pref("network.proxy.socks", "");',
'user_pref("network.proxy.socks_port", 0);',
'user_pref("network.proxy.socks_version", 5);',
'user_pref("network.proxy.socks_remote_dns", false);',
'user_pref("network.proxy.no_proxies_on", "");',
]
)
if hard.disable_webrtc:
# Off regardless of persona — WebRTC leaks real IP through STUN.
lines.extend(
[
'user_pref("media.peerconnection.enabled", false);',
'user_pref("media.peerconnection.ice.default_address_only", true);',
'user_pref("media.peerconnection.ice.no_host", true);',
]
)
if hard.resist_fingerprinting:
lines.extend(
[
'user_pref("privacy.resistFingerprinting", true);',
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
'user_pref("privacy.window.maxInnerWidth", 1600);',
'user_pref("privacy.window.maxInnerHeight", 900);',
]
)
if hard.first_party_isolation:
lines.append('user_pref("privacy.firstparty.isolate", true);')
if hard.disable_telemetry:
lines.append('user_pref("browser.send_pings", false);')
if hard.strict_tracking_protection:
lines.extend(
[
'user_pref("privacy.trackingprotection.enabled", true);',
'user_pref("privacy.trackingprotection.pbmode.enabled", true);',
]
)
# Persona spoofing (UA, accept-language, screen, platform)
if hard.persona is not None:
p = hard.persona
lines.extend(
[
f'user_pref("general.useragent.override", "{p.user_agent}");',
f'user_pref("intl.accept_languages", "{p.accept_language}");',
f'user_pref("general.useragent.locale", "{p.locale}");',
'user_pref("javascript.use_us_english_locale", true);',
f'user_pref("privacy.window.maxInnerWidth", {int(p.screen_w)});',
f'user_pref("privacy.window.maxInnerHeight", {int(p.screen_h)});',
# Hint timezone — Firefox honors TZ env var at launch (set by
# the launcher) but this pref nudges some site detection too.
f'user_pref("intl.locale.requested", "{p.locale}");',
]
)
elif hard.user_agent_override:
lines.append(
f'user_pref("general.useragent.override", "{hard.user_agent_override}");'
)
# ── Cookie policy ────────────────────────────────────────────────────────
cp: CookiePolicy = get_cookie_policy(hard.cookie_mode)
lines.extend([
f'user_pref("network.cookie.cookieBehavior", {cp.behavior});',
# pbmode = private-browsing mode uses same behavior
f'user_pref("network.cookie.cookieBehavior.pbmode", {cp.behavior});',
# lifetime: 0 = normal, 2 = session-only
f'user_pref("network.cookie.lifetimePolicy", {cp.lifetime});',
])
# Total Cookie Protection (TCP / Firefox partitioning)
if cp.partition:
lines.extend([
'user_pref("network.cookie.cookieBehavior.optInPartitioning", true);',
# Also isolate cache, localStorage, IndexedDB per top-level site
'user_pref("privacy.partition.serviceWorkers", true);',
'user_pref("privacy.partition.network_state", true);',
'user_pref("privacy.partition.bloburl_by_default", true);',
])
else:
lines.extend([
'user_pref("network.cookie.cookieBehavior.optInPartitioning", false);',
])
# SameSite=None cookies must be Secure (good hygiene regardless of mode)
lines.append('user_pref("network.cookie.sameSite.noneRequiresSecure", true);')
# ── Sanitize-on-shutdown ─────────────────────────────────────────────────
# The CookiePolicy sanitize flag takes priority; the hard.clear_on_shutdown
# toggle acts as an additional override for cookies+cache even when the
# cookie mode doesn't request it.
do_sanitize = cp.sanitize or hard.clear_on_shutdown
if do_sanitize:
lines.append('user_pref("privacy.sanitize.sanitizeOnShutdown", true);')
# Cookies
lines.append(f'user_pref("privacy.clearOnShutdown.cookies", {_bool(cp.clear_cookies or hard.clear_on_shutdown)});')
# Cache
lines.append(f'user_pref("privacy.clearOnShutdown.cache", {_bool(cp.clear_cache or hard.clear_on_shutdown)});')
# History
lines.append(f'user_pref("privacy.clearOnShutdown.history", {_bool(cp.clear_history or hard.clear_on_shutdown)});')
# Downloads list
lines.append('user_pref("privacy.clearOnShutdown.downloads", true);')
# Form / search bar autofill data
lines.append(f'user_pref("privacy.clearOnShutdown.formdata", {_bool(cp.clear_formdata or hard.clear_on_shutdown)});')
# Active sessions (HTTP auth, logged-in sites)
lines.append('user_pref("privacy.clearOnShutdown.sessions", true);')
# localStorage + IndexedDB
lines.append(f'user_pref("privacy.clearOnShutdown.localStorage", {_bool(cp.clear_ls or hard.clear_on_shutdown)});')
lines.append(f'user_pref("privacy.clearOnShutdown.indexedDB", {_bool(cp.clear_ls or hard.clear_on_shutdown)});')
# Service worker registrations
lines.append('user_pref("privacy.clearOnShutdown.serviceWorkers", true);')
# Offline web app cache
lines.append('user_pref("privacy.clearOnShutdown.offlineApps", true);')
else:
lines.append('user_pref("privacy.sanitize.sanitizeOnShutdown", false);')
if hard.timezone_utc:
lines.append('user_pref("privacy.resistFingerprinting.reduceTimerPrecision", true);')
lines.append("")
return "\n".join(lines)
def persona_env(hard: FirefoxHardening) -> dict[str, str]:
"""Environment overrides applied when launching Firefox under a persona.
Only the timezone needs the OS-level env var (TZ) — Firefox reads it
even when running under Windows.
"""
hard = apply_persona(hard)
if hard.persona is None:
return {}
return {"TZ": hard.persona.timezone}
def ensure_firefox_profile(
profile_dir: Path,
proxy_host: str,
proxy_port: int,
hard: FirefoxHardening,
) -> None:
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "user.js").write_text(
build_user_js(proxy_host, proxy_port, hard),
encoding="utf-8",
)

View File

@@ -0,0 +1,367 @@
"""World-map visualization for proxy chains.
Renders hop arcs over the bundled neon world map image. Falls back to a
drawn landmass map if the image file is absent.
"""
from __future__ import annotations
import ipaddress
import logging
import math
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from PIL import Image, ImageDraw, ImageFilter, ImageFont
try:
_RESAMPLE = Image.Resampling.LANCZOS
except AttributeError:
_RESAMPLE = Image.LANCZOS # type: ignore[attr-defined]
from .config import normalize_proxy_url, redact_proxy_url
from .exit_intel import ExitIntel, fetch_ip_geo
log = logging.getLogger(__name__)
ChainStatus = Literal["healthy", "connecting", "dead", "idle"]
# Theme palette
_BG = "#070713"
_GRID = "#1a1f3a"
_LAND = "#12182e"
_LAND_EDGE = "#1e2a4a"
_LINE_HEALTHY = "#00e5ff"
_LINE_CONNECTING = "#ffd000"
_LINE_DEAD = "#ff2a6d"
_LINE_IDLE = "#3052ff"
_YOU = "#00ff9c"
_HOP = "#00e5ff"
_EXIT = "#ffd000"
_LABEL = "#eaf2ff"
_LABEL_DIM = "#7a8aab"
_UNKNOWN = "#2a2f4a"
def _map_img_path() -> Path:
"""Locate world_map.png whether running from source or a frozen PyInstaller bundle."""
import sys
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "proxy_chain_manager" / "world_map.png"
return Path(__file__).resolve().parent / "world_map.png"
_cached_map: Image.Image | None = None
def _load_world_map(width: int, height: int) -> Image.Image | None:
"""Return the neon world map resized to (width × height), or None if unavailable."""
global _cached_map
try:
if _cached_map is None:
_cached_map = Image.open(_map_img_path()).convert("RGB")
return _cached_map.resize((width, height), _RESAMPLE)
except Exception as exc:
log.debug("world_map load failed: %s", exc)
return None
@dataclass(frozen=True)
class MapPoint:
role: Literal["you", "hop", "exit"]
label: str
lat: float
lon: float
detail: str = ""
hop_index: int = -1
def extract_hop_host(proxy_url: str) -> str | None:
"""Return hostname or IP from a proxy URL."""
p = urlparse(normalize_proxy_url(proxy_url))
host = (p.hostname or "").strip()
return host or None
def resolve_host_ip(host: str, timeout_seconds: float = 4.0) -> str | None:
"""Resolve host to IPv4/IPv6 string. Never raises."""
h = (host or "").strip()
if not h:
return None
try:
ipaddress.ip_address(h)
return h
except ValueError:
pass
try:
prev = socket.getdefaulttimeout()
socket.setdefaulttimeout(max(1.0, float(timeout_seconds)))
try:
infos = socket.getaddrinfo(h, None, type=socket.SOCK_STREAM)
finally:
socket.setdefaulttimeout(prev)
for info in infos:
addr = info[4][0]
try:
ipaddress.ip_address(addr)
return addr
except ValueError:
continue
except Exception as e:
log.debug("resolve_host_ip(%s): %s", h, e)
return None
def _loc_label(intel: ExitIntel) -> str:
bits = [b for b in (intel.city, intel.region, intel.country) if b]
return ", ".join(bits) if bits else intel.country_code or intel.ip or ""
def _intel_to_point(
role: Literal["you", "hop", "exit"],
label: str,
intel: ExitIntel,
*,
hop_index: int = -1,
) -> MapPoint | None:
if not intel.ok or intel.lat is None or intel.lon is None:
return None
return MapPoint(
role=role,
label=label,
lat=float(intel.lat),
lon=float(intel.lon),
detail=_loc_label(intel),
hop_index=hop_index,
)
def build_chain_map_points(
hops: list[str],
*,
direct_ip: str = "",
exit_ip: str = "",
exit_intel: ExitIntel | None = None,
timeout_seconds: float = 8.0,
) -> list[MapPoint]:
"""Resolve geo for YOU → hop₁ → … → EXIT. Never raises."""
points: list[MapPoint] = []
origin_ip = (direct_ip or "").strip()
if origin_ip and origin_ip != "":
you = _intel_to_point("you", "YOU", fetch_ip_geo(origin_ip, timeout_seconds))
if you:
points.append(you)
for i, hop in enumerate(hops):
host = extract_hop_host(hop)
if not host:
continue
ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds))
if not ip:
continue
intel = fetch_ip_geo(ip, timeout_seconds)
pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i)
if pt:
points.append(pt)
exit_str = (exit_ip or "").strip()
if exit_str and exit_str != "":
if (
exit_intel
and exit_intel.ok
and exit_intel.ip == exit_str
and exit_intel.lat is not None
and exit_intel.lon is not None
):
pt = _intel_to_point("exit", "EXIT", exit_intel)
else:
pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit_str, timeout_seconds))
if pt:
points.append(pt)
return points
def _project(lat: float, lon: float, width: int, height: int, margin: int) -> tuple[float, float]:
inner_w = max(1, width - margin * 2)
inner_h = max(1, height - margin * 2)
x = margin + (float(lon) + 180.0) / 360.0 * inner_w
y = margin + (90.0 - float(lat)) / 180.0 * inner_h
return x, y
def _great_circle_points(
lat1: float,
lon1: float,
lat2: float,
lon2: float,
steps: int = 32,
) -> list[tuple[float, float]]:
"""Interpolate along a great circle for curved hop arcs."""
phi1, lam1 = math.radians(lat1), math.radians(lon1)
phi2, lam2 = math.radians(lat2), math.radians(lon2)
d = 2 * math.asin(
math.sqrt(
math.sin((phi2 - phi1) / 2) ** 2
+ math.cos(phi1) * math.cos(phi2) * math.sin((lam2 - lam1) / 2) ** 2
)
)
if d < 1e-9:
return [(lat1, lon1), (lat2, lon2)]
out: list[tuple[float, float]] = []
for i in range(steps + 1):
f = i / steps
a = math.sin((1 - f) * d) / math.sin(d)
b = math.sin(f * d) / math.sin(d)
x = a * math.cos(phi1) * math.cos(lam1) + b * math.cos(phi2) * math.cos(lam2)
y = a * math.cos(phi1) * math.sin(lam1) + b * math.cos(phi2) * math.sin(lam2)
z = a * math.sin(phi1) + b * math.sin(phi2)
out.append((
math.degrees(math.atan2(z, math.sqrt(x * x + y * y))),
math.degrees(math.atan2(y, x)),
))
return out
def _line_color(status: ChainStatus) -> tuple[int, int, int]:
table = {
"healthy": (0, 229, 255),
"connecting": (255, 208, 0),
"dead": (255, 42, 109),
"idle": (48, 82, 255),
}
return table.get(status, (48, 82, 255))
def _node_color(role: str) -> tuple[int, int, int]:
table = {"you": (0, 255, 156), "hop": (0, 229, 255), "exit": (255, 208, 0)}
return table.get(role, (0, 229, 255))
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _draw_glow_line(
draw: ImageDraw.ImageDraw,
pts: list[tuple[float, float]],
color: tuple[int, int, int],
) -> None:
"""Draw neon glow arc: fat dim outer halo → bright core line."""
if len(pts) < 2:
return
r, g, b = color
# outer halo — wider, dim
halo = (r // 4, g // 4, b // 4)
draw.line(pts, fill=halo, width=7)
# mid glow
mid = (r // 2, g // 2, b // 2)
draw.line(pts, fill=mid, width=4)
# bright core
draw.line(pts, fill=color, width=2)
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for name in ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None:
"""Fallback minimal continent silhouettes."""
blobs: list[list[tuple[float, float]]] = [
[(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
[(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
[(-10, 72), (30, 70), (40, 55), (35, 30), (20, 5), (-5, 5), (-18, 28), (-10, 72)],
[(15, 35), (35, 32), (50, 12), (42, -5), (18, -35), (15, 35)],
[(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
[(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)],
]
for poly in blobs:
pts = [_project(lat, lon, width, height, margin) for lat, lon in poly]
draw.polygon(pts, fill=_LAND, outline=_LAND_EDGE)
def render_chain_map(
points: list[MapPoint],
*,
width: int = 960,
height: int = 220,
status: ChainStatus = "idle",
margin: int = 12,
) -> Image.Image:
"""Render chain hops over the neon world map (or a drawn fallback)."""
w = max(320, width)
h = max(120, height)
# ── background ──────────────────────────────────────────────────────────
world = _load_world_map(w, h)
if world is not None:
# Slightly darken so overlaid lines pop
bg = Image.new("RGB", (w, h), (0, 0, 0))
img = Image.blend(world, bg, alpha=0.22)
else:
img = Image.new("RGB", (w, h), _BG)
draw_bg = ImageDraw.Draw(img)
_draw_landmasses(draw_bg, w, h, margin)
# grid
for lat in range(-60, 91, 30):
y = _project(lat, 0, w, h, margin)[1]
draw_bg.line([(margin, y), (w - margin, y)], fill=_GRID, width=1)
for lon in range(-150, 181, 30):
x = _project(0, lon, w, h, margin)[0]
draw_bg.line([(x, margin), (x, h - margin)], fill=_GRID, width=1)
draw = ImageDraw.Draw(img)
if len(points) < 2:
font = _load_font(12)
msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path."
# drop shadow
draw.text((margin + 1, h // 2 - 7), msg, fill=(0, 0, 0), font=font)
draw.text((margin, h // 2 - 8), msg, fill=_hex_to_rgb(_LABEL_DIM), font=font)
return img
line_rgb = _line_color(status)
projected = [_project(p.lat, p.lon, w, h, margin) for p in points]
# ── arc lines between consecutive hops ─────────────────────────────────
for i in range(len(projected) - 1):
arc = _great_circle_points(
points[i].lat, points[i].lon,
points[i + 1].lat, points[i + 1].lon,
)
arc_xy = [_project(lat, lon, w, h, margin) for lat, lon in arc]
_draw_glow_line(draw, arc_xy, line_rgb)
# ── nodes ───────────────────────────────────────────────────────────────
label_font = _load_font(11)
detail_font = _load_font(9)
for pt, (x, y) in zip(points, projected):
nc = _node_color(pt.role)
r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
# outer glow ring
glow = (nc[0] // 3, nc[1] // 3, nc[2] // 3)
draw.ellipse((x - r - 4, y - r - 4, x + r + 4, y + r + 4), fill=glow)
# filled node with dark center
draw.ellipse((x - r, y - r, x + r, y + r), fill=nc)
draw.ellipse((x - r + 2, y - r + 2, x + r - 2, y + r - 2), fill=(5, 5, 20))
tx, ty = int(x + r + 5), int(y - 8)
# text drop-shadow
draw.text((tx + 1, ty + 1), pt.label, fill=(0, 0, 0), font=label_font)
draw.text((tx, ty), pt.label, fill=_hex_to_rgb(_LABEL), font=label_font)
if pt.detail:
draw.text((tx, ty + 12), pt.detail[:30], fill=_hex_to_rgb(_LABEL_DIM), font=detail_font)
return img
def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image:
"""Resize rendered map for the GUI widget."""
return pil_img.resize((max(320, int(width)), max(120, int(height))), _RESAMPLE)

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
import json
import logging
import shutil
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from urllib.parse import quote, unquote, urlparse, urlunparse
from .paths import app_data_dir
log = logging.getLogger(__name__)
# ── Settings schema version ───────────────────────────────────────────────────
# Bump this whenever a breaking field rename / type change is made so
# migrate() can upgrade old settings.json files safely.
SETTINGS_SCHEMA_VERSION = 1
# !! SECURITY NOTE !!
# settings.json is stored in plaintext at %LOCALAPPDATA%\ProxyChainManager\.
# It may contain proxy credentials (URLs with user:password). Do NOT log
# raw settings dicts — use _mask() to redact credential fields before logging.
def _mask(d: dict) -> dict:
"""Return a copy of settings dict with credential-bearing fields redacted."""
redact_keys = {"manual_exit_proxy", "pinned_chain", "sources"}
out: dict = {}
for k, v in d.items():
if k in redact_keys:
if isinstance(v, list):
out[k] = [redact_proxy_url(str(x)) for x in v]
else:
out[k] = redact_proxy_url(str(v))
else:
out[k] = v
return out
def _host_port_for_url(host: str, port: int | None) -> str:
"""``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals)."""
if not host:
return ""
if ":" in host and "." not in host:
inner = f"[{host}]"
else:
inner = host
return f"{inner}:{port}" if port else inner
# Always bind the local forwarder to loopback. VM clones and NIC/IP changes (DHCP)
# do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener.
LISTEN_HOST = "127.0.0.1"
def normalize_proxy_url(raw: str) -> str:
"""Strip, add http:// if missing. Empty input → empty string."""
t = (raw or "").strip()
if not t:
return ""
if "://" not in t:
t = "http://" + t
return t.rstrip("/")
def split_proxy_for_edit(raw: str) -> tuple[str, str, str]:
"""Split stored proxy URL into (url_without_credentials, username, password).
Used to populate fixed-exit / form fields. Credentials are percent-decoded.
"""
t = normalize_proxy_url((raw or "").strip())
if not t:
return "", "", ""
p = urlparse(t)
if not p.hostname:
return t, "", ""
host = p.hostname
port = p.port
scheme = p.scheme or "http"
base = f"{scheme}://{_host_port_for_url(host, port)}"
u = unquote(p.username) if p.username else ""
pw = unquote(p.password) if p.password else ""
return base, u, pw
def merge_proxy_credentials(raw_url: str, username: str = "", password: str = "") -> str:
"""Build a single proxy URL: host/port from ``raw_url`` (any embedded auth ignored) plus optional auth.
User and password are URL-encoded for ``@`` and ``:`` inside values.
"""
base = normalize_proxy_url((raw_url or "").strip())
if not base:
return ""
p = urlparse(base)
if not p.hostname:
return base
host = p.hostname
port = p.port
scheme = p.scheme or "http"
user = (username or "").strip()
pw = (password or "").strip()
if not user and not pw:
# Leave credentials embedded in URL when form fields are blank (paste user:pass@host).
return base
hp = _host_port_for_url(host, port)
netloc = f"{quote(user, safe='')}:{quote(pw, safe='')}@{hp}"
return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/")
def parse_exit_proxy_input(
raw: str, default_scheme: str = "socks5"
) -> tuple[str, str, str]:
"""Parse any sane paste format into ``(base_url, user, password)``.
Accepts (whitespace-trimmed, default scheme = SOCKS5 when none given):
``host:port``
``host:port:user:password``
``host:port:user:password:session_or_extra`` (extras folded into password)
``host:port user password`` (tab/space-separated)
``user:password@host:port``
``scheme://host:port``
``scheme://user:password@host:port``
User and password may contain ``@``, ``:`` and arbitrary "words" — those
are URL-encoded automatically when ``merge_proxy_credentials`` rebuilds
the URL, so pasting plain text is safe.
"""
t = (raw or "").strip()
if not t:
return "", "", ""
if "://" in t:
# Already a full URL — defer to the existing splitter.
return split_proxy_for_edit(t)
if "@" in t:
return split_proxy_for_edit(f"{default_scheme}://{t}")
if "\t" in t or any(c in t for c in (" ",)):
# "host:port user pass" or "host:port\tuser\tpass"
parts = [p for p in t.replace("\t", " ").split() if p]
if len(parts) >= 3:
hp, user, pw = parts[0], parts[1], " ".join(parts[2:])
return f"{default_scheme}://{hp}".rstrip("/"), user, pw
if len(parts) == 2:
return f"{default_scheme}://{parts[0]}".rstrip("/"), "", ""
t = parts[0]
bits = t.split(":")
if len(bits) >= 4:
host, port, user = bits[0], bits[1], bits[2]
password = ":".join(bits[3:]) # passwords containing ":" survive
return f"{default_scheme}://{host}:{port}", user, password
if len(bits) == 2:
return f"{default_scheme}://{bits[0]}:{bits[1]}", "", ""
# 1 token, or 3-token oddity — best-effort; treat as host[:port] only.
return f"{default_scheme}://{t}".rstrip("/"), "", ""
def redact_proxy_url(url: str) -> str:
"""Same URL shape for logs/UI, with credentials replaced by ``***``."""
t = (url or "").strip()
if not t:
return ""
if "://" not in t:
t = "http://" + t
p = urlparse(t.rstrip("/"))
if not p.hostname:
return t
has_auth = bool(p.username) or bool(p.password)
if not has_auth:
return t.rstrip("/")
scheme = p.scheme or "http"
hp = _host_port_for_url(p.hostname, p.port)
netloc = f"***:***@{hp}"
return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/")
OBFUSCATION_MODES = ["auto", "http_only", "socks5_only", "random_mix"]
OBFUSCATION_LABELS = {
"auto": "Auto (best available)",
"http_only": "HTTP only",
"socks5_only": "SOCKS5 only",
"random_mix": "Random Mix (max noise)",
}
@dataclass
class Settings:
# Schema version — used by migrate() to upgrade old settings.json files.
settings_version: int = SETTINGS_SCHEMA_VERSION
# ── network ──────────────────────────────────────────────────────────
local_host: str = LISTEN_HOST
local_port: int = 18888
# ── chain ─────────────────────────────────────────────────────────────
chain_length: int = 3 # number of hops (2-8)
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
validate_pinned_on_start: bool = True # quick TCP probe of each pinned hop before chaining
max_browser_relaunches: int = 5 # cap auto-relaunch attempts so a broken profile doesn't loop
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
manual_exit_proxy: str = ""
# ── timing ────────────────────────────────────────────────────────────
health_check_seconds: int = 180 # how often to re-verify exit IP
full_refresh_seconds: int = 1800 # how often to re-fetch/re-validate pool
# ── validation ────────────────────────────────────────────────────────
validation_concurrency: int = 64
max_candidates: int = 200 # cap before validation (shuffled, so random sample)
min_pool_size: int = 20 # stop validating once this many good proxies found
validation_timeout_seconds: float = 12.0
prefer_elite: bool = False
# ── security ──────────────────────────────────────────────────────────
kill_switch_enabled: bool = sys.platform == "win32" # Windows Firewall kill-switch when available
proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>"
# ── privacy / device hardening (Privacy tab) ───────────────────────────
mac_spoof_enabled: bool = False # randomize NIC MAC while chain runs (Admin)
mac_rotate_on_chain_rotate: bool = False # re-randomize MAC on every chain rotation
spoof_hostname_enabled: bool = False # temporary computer name while chain runs (Admin)
flush_dns_on_rotate: bool = True # ipconfig /flushdns on each rotation
disable_ipv6_while_active: bool = False # disable IPv6 bindings while chain runs (Admin)
harden_webrtc_enabled: bool = False # Chrome/Edge WebRTC policy (Admin)
lan_lockdown_enabled: bool = False # kill LLMNR/NetBIOS/mDNS while chain runs
telemetry_kill_enabled: bool = False # disable DiagTrack + Activity History (Admin)
# ── hardened browser ────────────────────────────────────────────────────
firefox_path: str = ""
firefox_profile_dir: str = ""
browser_clear_on_close: bool = True
browser_disposable_profile: bool = False
browser_kill_on_chain_drop: bool = True
browser_auto_relaunch: bool = False
browser_lock_managed_profile: bool = True
browser_force_proxy: bool = True
browser_disable_webrtc: bool = True
browser_resist_fingerprinting: bool = True
browser_disable_telemetry: bool = True
browser_first_party_isolation: bool = True
browser_strict_tracking_protection: bool = True
browser_timezone_utc: bool = True
browser_persona: str = "blend_windows_chrome"
browser_cookie_mode: str = "block_third_party"
# ── sources ───────────────────────────────────────────────────────────
sources: list[str] = field(
default_factory=lambda: [
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/socks5/data.json",
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/https/data.json",
]
)
ip_check_url: str = "https://api.ipify.org?format=json"
def listen_addr(self) -> str:
return f"{self.local_host}:{self.local_port}"
def settings_path(self) -> Path:
return app_data_dir() / "settings.json"
def _is_safe_https_url(url: str) -> bool:
"""Return True for HTTPS URLs pointing at public hosts (not RFC-1918/link-local)."""
try:
p = urlparse(url)
if p.scheme != "https":
return False
host = p.hostname or ""
if not host:
return False
import ipaddress as _ip
try:
addr = _ip.ip_address(host)
return not (addr.is_private or addr.is_loopback or addr.is_link_local)
except ValueError:
pass
return True
except Exception:
return False
def migrate(raw: dict) -> dict:
"""Upgrade a raw settings dict from any schema version to the current one.
Each version block transforms ``raw`` in-place and bumps
``settings_version``. New fields are left absent so the dataclass
default takes effect.
"""
ver = int(raw.get("settings_version", 0))
# v0 → v1: no structural changes; just stamp the version
if ver < 1:
raw["settings_version"] = 1
return raw
def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
changed = False
try:
cl = int(s.chain_length)
if not (1 <= cl <= 8):
s.chain_length = max(1, min(8, cl))
changed = True
except (TypeError, ValueError):
s.chain_length = 3
changed = True
try:
p = int(s.local_port)
if not (1 <= p <= 65535):
s.local_port = max(1, min(65535, p))
changed = True
except (TypeError, ValueError):
s.local_port = 18888
changed = True
try:
hc = int(s.health_check_seconds)
if hc < 10:
s.health_check_seconds = 10
changed = True
elif hc > 3600:
s.health_check_seconds = 3600
changed = True
except (TypeError, ValueError):
s.health_check_seconds = 180
changed = True
try:
fr = int(s.full_refresh_seconds)
if fr < 60:
s.full_refresh_seconds = 60
changed = True
elif fr > 86400:
s.full_refresh_seconds = 86400
changed = True
except (TypeError, ValueError):
s.full_refresh_seconds = 1800
changed = True
try:
if int(s.validation_concurrency) < 1:
s.validation_concurrency = 1
changed = True
except (TypeError, ValueError):
s.validation_concurrency = 64
changed = True
try:
if int(s.max_candidates) < 10:
s.max_candidates = 10
changed = True
except (TypeError, ValueError):
s.max_candidates = 200
changed = True
try:
mps = int(s.min_pool_size)
if mps < 3:
s.min_pool_size = 3
changed = True
elif mps > 500:
s.min_pool_size = 500
changed = True
except (TypeError, ValueError):
s.min_pool_size = 20
changed = True
if not hasattr(s, "min_pool_size"):
s.min_pool_size = 20
changed = True
try:
vt = float(s.validation_timeout_seconds)
if vt < 2.0:
s.validation_timeout_seconds = 2.0
changed = True
elif vt > 120.0:
s.validation_timeout_seconds = 120.0
changed = True
except (TypeError, ValueError):
s.validation_timeout_seconds = 12.0
changed = True
# Empty list survives "all(isinstance…)" — would skip all fetches and yield an empty pool.
if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources) or len(s.sources) == 0:
s.sources = list(Settings().sources)
changed = True
else:
safe = [u for u in s.sources if _is_safe_https_url(u)]
if len(safe) < len(s.sources):
log.warning("Removed %d unsafe source URL(s) from settings.", len(s.sources) - len(safe))
s.sources = safe if safe else list(Settings().sources)
changed = True
if s.ip_check_url and not _is_safe_https_url(s.ip_check_url):
log.warning("ip_check_url looks unsafe (%s) — reverting to default.", s.ip_check_url)
s.ip_check_url = Settings().ip_check_url
changed = True
return s, changed
def normalize_listen_host(s: Settings) -> tuple[Settings, bool]:
"""Force loopback bind so LAN/DHCP IP changes (e.g. cloned VMs) never break the listener."""
if s.local_host == LISTEN_HOST:
return s, False
s.local_host = LISTEN_HOST
return s, True
def load_settings() -> Settings:
p = app_data_dir() / "settings.json"
if not p.is_file():
s = Settings()
if sys.platform != "win32":
s.kill_switch_enabled = False
s.mac_spoof_enabled = False
s.mac_rotate_on_chain_rotate = False
s.spoof_hostname_enabled = False
s.disable_ipv6_while_active = False
s.harden_webrtc_enabled = False
s.lan_lockdown_enabled = False
s.telemetry_kill_enabled = False
return s
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
corrupt = p.with_suffix(".json.corrupt")
log.warning("settings.json unreadable (%s) — backing up to %s and using defaults.", exc, corrupt)
try:
shutil.copy2(p, corrupt)
except OSError:
pass
return Settings()
raw = migrate(raw)
s = Settings()
for k, v in raw.items():
if hasattr(s, k):
setattr(s, k, v)
if (
not isinstance(s.sources, list)
or not all(isinstance(x, str) for x in s.sources)
or len(s.sources) == 0
):
s.sources = list(Settings().sources)
if s.obfuscation_mode not in OBFUSCATION_MODES:
s.obfuscation_mode = "auto"
if not isinstance(s.pinned_chain, list):
s.pinned_chain = []
if not hasattr(s, "manual_exit_proxy") or not isinstance(s.manual_exit_proxy, str):
s.manual_exit_proxy = ""
s, ch1 = sanitize_settings(s)
s, ch2 = normalize_listen_host(s)
changed = ch1 or ch2
if changed:
try:
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
except OSError:
pass
return s
def save_settings(s: Settings) -> None:
s, _ = sanitize_settings(s)
s, _ = normalize_listen_host(s)
p = app_data_dir() / "settings.json"
# Backup before overwriting so a crash mid-write doesn't corrupt settings.
if p.is_file():
try:
shutil.copy2(p, p.with_suffix(".json.bak"))
except OSError as exc:
log.warning("Could not back up settings.json: %s", exc)
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")

View File

@@ -0,0 +1,335 @@
"""DNS leak detection and cache flushing.
Two levels of testing:
1. check_dns_leak_hint() — fast, no network: inspect configured DNS resolvers
2. run_dns_leak_test() — real network test: compare DNS answers seen through
the proxy vs direct, and probe resolver identity via
a dedicated leak-test API.
"""
from __future__ import annotations
import concurrent.futures
import ipaddress
import logging
import socket
import subprocess
import sys
from dataclasses import dataclass, field
import httpx
log = logging.getLogger(__name__)
# ─── DoH endpoint used to ask "what is my apparent source IP?" ──────────────
_DOH_IP_CHECK = "https://api.ipify.org?format=json"
_DNS_LEAK_API = "https://dnsleaktest.com/api/v1/start"
_DNS_LEAK_RESULT = "https://dnsleaktest.com/api/v1/results"
# Well-known public resolver IP ranges (prefix → label)
_PUBLIC_DNS_LABELS: dict[str, str] = {
"8.8.8.8": "Google", "8.8.4.4": "Google",
"1.1.1.1": "Cloudflare", "1.0.0.1": "Cloudflare",
"9.9.9.9": "Quad9", "149.112.112.112": "Quad9",
"208.67.222.222": "OpenDNS", "208.67.220.220": "OpenDNS",
"76.76.2.0": "Alternate DNS",
"94.140.14.14": "AdGuard",
}
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class DnsLeakResult:
"""Legacy single-check result kept for API compatibility."""
ok: bool
system_resolvers: list[str]
message: str
doh_ip: str | None = None
@dataclass
class DnsResolverInfo:
ip: str
hostname: str = ""
country: str = ""
isp: str = ""
label: str = "" # "Google", "Cloudflare", or ""
is_public_known: bool = False
@dataclass
class FullDnsLeakReport:
"""Comprehensive DNS leak report from run_dns_leak_test()."""
# Resolvers that answered DNS queries in this session
resolvers_seen: list[DnsResolverInfo] = field(default_factory=list)
# Resolvers configured by the OS
system_resolvers: list[str] = field(default_factory=list)
# IPs resolved for test hostnames — via proxy vs direct
proxy_resolution: dict[str, list[str]] = field(default_factory=dict)
direct_resolution: dict[str, list[str]] = field(default_factory=dict)
# True when proxy and direct give the same answers (possible leak)
resolution_matches_direct: bool = False
# Whether any configured resolver is a known public server
has_public_resolver: bool = False
# Whether any configured resolver is outside local network
has_external_resolver: bool = False
# Overall verdict
leaked: bool = False
summary: str = ""
errors: list[str] = field(default_factory=list)
# ─────────────────────────────────────────────────────────────────────────────
# System resolver detection
# ─────────────────────────────────────────────────────────────────────────────
def get_system_dns_servers() -> list[str]:
"""Return configured IPv4 DNS resolvers via PowerShell (falls back to ipconfig)."""
if sys.platform == "darwin":
try:
r = subprocess.run(["scutil", "--dns"], capture_output=True, text=True, timeout=10)
servers: list[str] = []
for line in (r.stdout or "").splitlines():
line = line.strip()
if line.startswith("nameserver["):
ip = line.split(":", 1)[-1].strip()
if ip and ip not in servers:
servers.append(ip)
return servers
except Exception:
return []
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-DnsClientServerAddress -AddressFamily IPv4 | "
"Where-Object { $_.ServerAddresses } | "
"Select-Object -ExpandProperty ServerAddresses) -join ','"],
capture_output=True, text=True, timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (r.stdout or "").strip()
if raw:
return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()]
except Exception as exc:
log.debug("PowerShell DNS server query failed (will try ipconfig): %s", exc)
# ipconfig fallback
try:
r = subprocess.run(
["ipconfig", "/all"], capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
servers: list[str] = []
for line in (r.stdout or "").splitlines():
if "DNS Servers" in line or "DNS Server" in line:
parts = line.split(":", 1)
if len(parts) == 2:
ip = parts[1].strip()
if ip:
servers.append(ip)
return servers
except Exception:
return []
def flush_dns_cache() -> tuple[bool, str]:
if sys.platform == "darwin":
try:
subprocess.run(["dscacheutil", "-flushcache"], capture_output=True, timeout=15)
subprocess.run(["killall", "-HUP", "mDNSResponder"], capture_output=True, timeout=15)
return True, "macOS DNS cache flushed"
except Exception as e:
return False, str(e)
try:
r = subprocess.run(
["ipconfig", "/flushdns"], capture_output=True, text=True, timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
lines = (r.stdout or r.stderr or "").strip().splitlines()
msg = lines[-1] if lines else "flushed"
return r.returncode == 0, msg
except Exception as e:
return False, str(e)
# ─────────────────────────────────────────────────────────────────────────────
# Hostname resolution helpers
# ─────────────────────────────────────────────────────────────────────────────
def _resolve_direct(hostname: str, timeout: float = 4.0) -> list[str]:
"""Resolve hostname using system DNS (direct, not through proxy)."""
_prev = socket.getdefaulttimeout()
try:
socket.setdefaulttimeout(timeout)
infos = socket.getaddrinfo(hostname, None)
seen: list[str] = []
for info in infos:
addr = info[4][0]
if addr and addr not in seen:
seen.append(addr)
return seen
except Exception:
return []
finally:
socket.setdefaulttimeout(_prev)
def _resolve_via_proxy(hostname: str, proxy_url: str, timeout: float = 8.0) -> list[str]:
"""Resolve hostname by fetching a DNS-over-HTTPS endpoint through the proxy."""
# Use Google DoH JSON API through the proxy so DNS is resolved on the exit node
doh = f"https://dns.google/resolve?name={hostname}&type=A"
try:
with httpx.Client(proxy=proxy_url, timeout=timeout, verify=False, follow_redirects=True) as c:
r = c.get(doh)
if r.status_code == 200:
data = r.json()
return [ans["data"] for ans in (data.get("Answer") or [])
if ans.get("type") == 1]
except Exception as exc:
log.debug("DoH DNS leak query failed: %s", exc)
return []
def _is_private(ip: str) -> bool:
try:
return ipaddress.ip_address(ip).is_private
except Exception:
return False
# ─────────────────────────────────────────────────────────────────────────────
# Main leak test
# ─────────────────────────────────────────────────────────────────────────────
_LEAK_TEST_HOSTS = [
"google.com",
"cloudflare.com",
"github.com",
"amazon.com",
]
def run_dns_leak_test(
proxy_url: str | None = None,
timeout: float = 10.0,
) -> FullDnsLeakReport:
"""
Comprehensive DNS leak test:
- Collect system DNS resolver configuration
- Resolve test hostnames both directly (system DNS) and via proxy (DoH through chain)
- Compare answers: matching answers suggest DNS is NOT going through the proxy
- Flag known public resolvers that would bypass the chain
"""
rep = FullDnsLeakReport()
rep.system_resolvers = get_system_dns_servers()
_PRIVATE_PREFIXES = (
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.", "169.254.",
)
for r_ip in rep.system_resolvers:
is_public_known = r_ip in _PUBLIC_DNS_LABELS
is_external = not any(r_ip.startswith(p) for p in _PRIVATE_PREFIXES)
label = _PUBLIC_DNS_LABELS.get(r_ip, "")
rep.has_public_resolver = rep.has_public_resolver or is_public_known
rep.has_external_resolver = rep.has_external_resolver or is_external
rep.resolvers_seen.append(DnsResolverInfo(
ip=r_ip,
label=label,
is_public_known=is_public_known,
))
# Resolve test hosts in parallel
all_match = True
any_resolved_via_proxy = False
def _test_host(host: str) -> tuple[str, list[str], list[str]]:
direct = _resolve_direct(host, timeout=min(4.0, timeout))
proxy = _resolve_via_proxy(host, proxy_url, timeout=timeout) if proxy_url else []
return host, direct, proxy
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futs = {pool.submit(_test_host, h): h for h in _LEAK_TEST_HOSTS}
for fut in concurrent.futures.as_completed(futs, timeout=timeout + 2):
try:
host, direct, proxy = fut.result()
rep.direct_resolution[host] = direct
rep.proxy_resolution[host] = proxy
if proxy:
any_resolved_via_proxy = True
# Overlap: if proxy and direct gave the same IPs, DNS
# might not be going through the proxy (same resolver path)
direct_set = set(direct)
proxy_set = set(proxy)
if direct_set and proxy_set and direct_set == proxy_set:
pass # this host matches
else:
all_match = False
except Exception as e:
rep.errors.append(str(e))
except Exception as e:
rep.errors.append(f"Thread pool error: {e}")
rep.resolution_matches_direct = all_match and any_resolved_via_proxy
# ── Build verdict ──────────────────────────────────────────────────────
problems: list[str] = []
if rep.has_external_resolver:
external = [r for r in rep.system_resolvers
if not any(r.startswith(p) for p in _PRIVATE_PREFIXES)]
names = [f"{ip} ({_PUBLIC_DNS_LABELS[ip]})" if ip in _PUBLIC_DNS_LABELS else ip
for ip in external[:4]]
problems.append(f"OS DNS: {', '.join(names)} — DNS may bypass proxy chain")
if rep.resolution_matches_direct and proxy_url:
problems.append("Proxy DNS resolution matches direct — possible DNS leak (same upstream resolver)")
if problems:
rep.leaked = True
rep.summary = " · ".join(problems)
else:
rep.leaked = False
if proxy_url:
rep.summary = "DNS routing looks clean — proxy resolves differently from direct."
else:
resolver_str = ", ".join(rep.system_resolvers[:3]) if rep.system_resolvers else "none detected"
rep.summary = f"System resolvers: {resolver_str}"
return rep
# ─────────────────────────────────────────────────────────────────────────────
# Legacy quick hint (used by Privacy tab DNS section)
# ─────────────────────────────────────────────────────────────────────────────
def check_dns_leak_hint(local_proxy: str | None = None) -> DnsLeakResult:
"""Fast heuristic: inspect configured DNS resolvers, flag public ones."""
resolvers = get_system_dns_servers()
private_prefixes = (
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.",
)
public = [r for r in resolvers if not any(r.startswith(p) for p in private_prefixes)]
if not resolvers:
return DnsLeakResult(ok=True, system_resolvers=[], message="No IPv4 DNS servers reported (DHCP).")
if public and local_proxy:
return DnsLeakResult(
ok=False, system_resolvers=resolvers,
message=(
f"DNS may bypass proxy chain: public resolvers {', '.join(public)}. "
"Use kill-switch + VPN, or set DNS to localhost when hardened."
),
)
if public:
return DnsLeakResult(
ok=False, system_resolvers=resolvers,
message=f"Public DNS resolvers active: {', '.join(public)}",
)
return DnsLeakResult(
ok=True, system_resolvers=resolvers,
message=f"DNS servers: {', '.join(resolvers)}",
)

View File

@@ -0,0 +1,226 @@
"""Exit IP intelligence: geo, ASN, datacenter heuristics.
Designed to be called through the local chain proxy so the lookup itself
flows over the active path (no out-of-band leak). Uses ip-api.com (free,
no key) with a fallback to ipwho.is. Both return geo + ASN.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import httpx
log = logging.getLogger(__name__)
# Common datacenter / hosting ASN keywords. Used as a quick heuristic to
# warn the operator before signup (residential ASNs are far less likely to
# trip Google/Proton anti-fraud than DC ranges).
_DC_KEYWORDS = (
"amazon", "aws", "google", "microsoft", "azure", "digitalocean",
"linode", "ovh", "hetzner", "vultr", "choopa", "scaleway",
"leaseweb", "contabo", "hostinger", "godaddy", "namecheap",
"cloudflare", "fastly", "akamai", "datacamp", "m247",
"psychz", "quadranet", "colocrossing", "rackspace",
"alibaba", "tencent", "huawei", "online s.a.s",
"wholesale", "datacenter", "data center", "hosting",
"server", "cloud", "vps",
)
@dataclass
class ExitIntel:
ok: bool
ip: str = ""
country: str = ""
country_code: str = ""
city: str = ""
region: str = ""
timezone: str = ""
lat: float | None = None
lon: float | None = None
asn: str = ""
org: str = ""
isp: str = ""
is_datacenter: bool = False
is_mobile: bool = False
is_proxy_flagged: bool = False
source: str = ""
detail: str = ""
def summary(self) -> str:
if not self.ok:
return f"unknown — {self.detail}"
loc_bits = [b for b in (self.city, self.region, self.country) if b]
loc = ", ".join(loc_bits) if loc_bits else ""
tags: list[str] = []
if self.is_datacenter:
tags.append("DATACENTER")
if self.is_mobile:
tags.append("MOBILE")
if self.is_proxy_flagged:
tags.append("PROXY-FLAGGED")
tag_str = f" [{' · '.join(tags)}]" if tags else ""
asn_str = f" AS{self.asn}" if self.asn else ""
return f"{self.ip} {loc}{asn_str}{tag_str}"
def _looks_dc(org: str, isp: str) -> bool:
haystack = f"{org} {isp}".lower()
return any(k in haystack for k in _DC_KEYWORDS)
def _parse_ipapi(payload: dict[str, Any]) -> ExitIntel:
if (payload.get("status") or "").lower() != "success":
return ExitIntel(ok=False, detail=str(payload.get("message") or "ip-api error"))
asn_raw = str(payload.get("as") or "")
asn = asn_raw.split()[0].lstrip("AS").strip() if asn_raw else ""
org = str(payload.get("org") or payload.get("isp") or "")
isp = str(payload.get("isp") or "")
lat_raw, lon_raw = payload.get("lat"), payload.get("lon")
return ExitIntel(
ok=True,
ip=str(payload.get("query") or ""),
country=str(payload.get("country") or ""),
country_code=str(payload.get("countryCode") or ""),
city=str(payload.get("city") or ""),
region=str(payload.get("regionName") or ""),
timezone=str(payload.get("timezone") or ""),
lat=float(lat_raw) if lat_raw is not None else None,
lon=float(lon_raw) if lon_raw is not None else None,
asn=asn,
org=org,
isp=isp,
is_datacenter=bool(payload.get("hosting")) or _looks_dc(org, isp),
is_mobile=bool(payload.get("mobile")),
is_proxy_flagged=bool(payload.get("proxy")),
source="ip-api.com",
detail="ok",
)
def _parse_ipwhois(payload: dict[str, Any]) -> ExitIntel:
if not payload.get("success", True):
return ExitIntel(ok=False, detail=str(payload.get("message") or "ipwho.is error"))
conn = payload.get("connection") or {}
asn_val = conn.get("asn")
asn = str(asn_val) if asn_val is not None else ""
org = str(conn.get("org") or "")
isp = str(conn.get("isp") or "")
lat_raw, lon_raw = payload.get("latitude"), payload.get("longitude")
return ExitIntel(
ok=True,
ip=str(payload.get("ip") or ""),
country=str(payload.get("country") or ""),
country_code=str(payload.get("country_code") or ""),
city=str(payload.get("city") or ""),
region=str(payload.get("region") or ""),
timezone=str((payload.get("timezone") or {}).get("id") or ""),
lat=float(lat_raw) if lat_raw is not None else None,
lon=float(lon_raw) if lon_raw is not None else None,
asn=asn,
org=org,
isp=isp,
is_datacenter=_looks_dc(org, isp),
is_mobile=False,
is_proxy_flagged=False,
source="ipwho.is",
detail="ok",
)
_IPAPI_FIELDS = (
"status,message,country,countryCode,regionName,city,lat,lon,timezone,"
"isp,org,as,mobile,proxy,hosting,query"
)
def _fetch_intel(
*,
proxy_url: str | None,
timeout_seconds: float,
) -> ExitIntel:
"""Shared ip-api / ipwho.is lookup. ``proxy_url=None`` uses a direct connection."""
timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds))
sources = [
(f"http://ip-api.com/json/?fields={_IPAPI_FIELDS}", _parse_ipapi),
("https://ipwho.is/", _parse_ipwhois),
]
last_err = ""
try:
with httpx.Client(
proxy=proxy_url,
timeout=timeout,
verify=False,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
) as c:
for url, parser in sources:
try:
r = c.get(url)
if r.status_code != 200 or not r.content:
last_err = f"{url} -> HTTP {r.status_code}"
continue
data = r.json()
out = parser(data)
if out.ok:
return out
last_err = out.detail
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
return ExitIntel(ok=False, detail=f"{type(e).__name__}: {e}")
return ExitIntel(ok=False, detail=last_err or "no source responded")
def fetch_exit_intel(proxy_url: str, timeout_seconds: float = 12.0) -> ExitIntel:
"""Look up exit IP geo/ASN/datacenter through the given proxy.
Returns an ExitIntel with ok=False and detail set on failure. Never raises.
"""
return _fetch_intel(proxy_url=proxy_url, timeout_seconds=timeout_seconds)
def fetch_ip_geo(ip: str, timeout_seconds: float = 8.0) -> ExitIntel:
"""Direct geo lookup for a specific IP (used for hop / origin mapping).
Never raises.
"""
target = (ip or "").strip()
if not target:
return ExitIntel(ok=False, detail="empty ip")
timeout = httpx.Timeout(timeout_seconds, connect=min(5.0, timeout_seconds))
sources = [
(
f"http://ip-api.com/json/{target}?fields={_IPAPI_FIELDS}",
_parse_ipapi,
),
(f"https://ipwho.is/{target}", _parse_ipwhois),
]
last_err = ""
try:
with httpx.Client(
timeout=timeout,
verify=False,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
) as c:
for url, parser in sources:
try:
r = c.get(url)
if r.status_code != 200 or not r.content:
last_err = f"{url} -> HTTP {r.status_code}"
continue
data = r.json()
out = parser(data)
if out.ok:
return out
last_err = out.detail
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
return ExitIntel(ok=False, detail=f"{type(e).__name__}: {e}")
return ExitIntel(ok=False, detail=last_err or "no source responded")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import logging
from typing import Any
import httpx
log = logging.getLogger(__name__)
def fetch_proxy_json(url: str, timeout: float = 45.0) -> list[dict[str, Any]]:
log.debug("fetch_proxy_json: GET %s", (url[:100] + "") if len(url) > 100 else url)
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
return []
return [x for x in data if isinstance(x, dict)]
def normalize_entries(rows: list[dict[str, Any]], prefer_elite: bool) -> list[str]:
out: list[str] = []
for row in rows:
if prefer_elite and str(row.get("anonymity", "")).lower() != "elite":
continue
p = row.get("proxy")
if isinstance(p, str) and "://" in p:
out.append(p.strip())
# de-dupe preserving order
seen: set[str] = set()
uniq: list[str] = []
for u in out:
if u not in seen:
seen.add(u)
uniq.append(u)
return uniq

View File

@@ -0,0 +1,416 @@
"""Device fingerprint audit and OS-level hardening helpers."""
from __future__ import annotations
import logging
import platform
import random
import re
import string
import subprocess
import winreg
from dataclasses import dataclass, field
from .firewall import is_admin
from .mac_spoof import list_nics
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
_WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome"
_WEBRTC_EDGE = r"SOFTWARE\Policies\Microsoft\Edge"
# Legacy DWORD key — value 3 = disable_non_proxied_udp (value 2 was wrong: public+private only)
_WEBRTC_VALUE = "DefaultWebRtcIpHandlingPolicy"
_WEBRTC_DISABLE = 3 # disable_non_proxied_udp (correct Chrome/Edge DWORD)
# Modern REG_SZ key required by Chrome 114+ Group Policy
_WEBRTC_VALUE_STR = "WebRtcIPHandling"
_WEBRTC_DISABLE_STR = "disable_non_proxied_udp"
@dataclass
class FingerprintAudit:
lines: list[str] = field(default_factory=list)
hostname: str = ""
machine_guid: str = ""
username: str = ""
macs: list[str] = field(default_factory=list)
def _run_ps(script: str, timeout: float = 15.0) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip()
except Exception as e:
log.debug("fingerprint ps: %s", e)
return ""
def get_computer_name() -> str:
try:
import os
return os.environ.get("COMPUTERNAME", "") or platform.node() or ""
except Exception:
return ""
def get_machine_guid() -> str:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography",
) as key:
val, _ = winreg.QueryValueEx(key, "MachineGuid")
return str(val)
except OSError:
return ""
def random_hostname(prefix: str = "PC") -> str:
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=7))
return f"{prefix}-{suffix}"[:15]
def set_computer_name(name: str) -> tuple[bool, str]:
"""Set NetBIOS / computer name (Admin). Reboot may be required for all apps."""
if not is_admin():
return False, "Administrator required to change computer name."
name = re.sub(r"[^A-Za-z0-9\-]", "", name)[:15]
if len(name) < 1:
return False, "Invalid hostname."
code = subprocess.run(
["powershell", "-NoProfile", "-Command",
f'Rename-Computer -NewName "{name}" -Force -ErrorAction Stop'],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).returncode
if code != 0:
# NetBIOS name via WMI (often works without full rename)
wmi = (
f'$n = Get-WmiObject Win32_ComputerSystem; '
f'$r = $n.Rename("{name}"); if ($r.ReturnValue -ne 0) {{ exit $r.ReturnValue }}'
)
code = subprocess.run(
["powershell", "-NoProfile", "-Command", wmi],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).returncode
if code == 0:
return True, f"Computer name set to {name} (some apps need reconnect/reboot)."
return False, "Could not change computer name."
def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
"""Disable IPv6 binding on up physical adapters. Returns (adapter names, log lines)."""
if not is_admin():
return [], ["IPv6 disable skipped (not Admin)."]
if not _win_probe().has_net_cmdlets:
return [], [
"IPv6 disable skipped — requires PowerShell 3.0+ "
"(Windows 8 / Server 2012+). Detected legacy PowerShell."
]
script = (
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
"ForEach-Object { $_.Name }"
)
names = [n.strip() for n in _run_ps(script).splitlines() if n.strip()]
logs: list[str] = []
changed: list[str] = []
skip = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth")
for name in names:
if any(h in name.lower() for h in skip):
continue
cmd = (
f'Disable-NetAdapterBinding -Name "{name}" -ComponentID ms_tcpip6 -Confirm:$false '
f'-ErrorAction SilentlyContinue'
)
subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
changed.append(name)
logs.append(f"IPv6 disabled on {name}")
return changed, logs
def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]:
if not is_admin() or not adapters:
return []
if not _win_probe().has_net_cmdlets:
return []
logs: list[str] = []
for name in adapters:
cmd = (
f'Enable-NetAdapterBinding -Name "{name}" -ComponentID ms_tcpip6 -Confirm:$false '
f'-ErrorAction SilentlyContinue'
)
subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"IPv6 re-enabled on {name}")
return logs
def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]:
"""Chrome/Edge: disable WebRTC non-proxied UDP (Admin, HKLM policies).
Writes both the legacy DWORD key (DefaultWebRtcIpHandlingPolicy=3) and
the modern REG_SZ key (WebRtcIPHandling=disable_non_proxied_udp) so that
all Chrome/Edge versions are covered.
"""
if not is_admin():
return False, "Administrator required for browser WebRTC policy."
paths = [_WEBRTC_CHROME, _WEBRTC_EDGE]
try:
for path in paths:
if enable:
try:
key = winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
)
except OSError:
continue
with key:
winreg.SetValueEx(key, _WEBRTC_VALUE, 0, winreg.REG_DWORD, _WEBRTC_DISABLE)
winreg.SetValueEx(key, _WEBRTC_VALUE_STR, 0, winreg.REG_SZ, _WEBRTC_DISABLE_STR)
else:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
for val_name in (_WEBRTC_VALUE, _WEBRTC_VALUE_STR):
try:
winreg.DeleteValue(key, val_name)
except OSError:
pass
except OSError:
pass
return True, (
"WebRTC hardened (Chrome/Edge: non-proxied UDP disabled)."
if enable
else "WebRTC policy removed."
)
except OSError as e:
return False, str(e)
def _get_os_info() -> str:
try:
return f"{platform.system()} {platform.release()} {platform.machine()}"
except Exception:
return "unknown"
def _get_timezone() -> str:
try:
import datetime
tz = datetime.datetime.now(datetime.timezone.utc).astimezone()
name = str(tz.tzname() or "")
offset = tz.utcoffset()
h = int(offset.total_seconds() // 3600) if offset else 0
return f"{name} (UTC{h:+d})" if name else f"UTC{h:+d}"
except Exception:
return "unknown"
def _get_screen_resolution() -> str:
if platform.system() == "Darwin":
try:
r = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True,
text=True,
timeout=8,
)
for line in (r.stdout or "").splitlines():
if "Resolution:" in line:
return line.split("Resolution:", 1)[1].strip()
except Exception:
return "unknown"
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Add-Type -AssemblyName System.Windows.Forms;"
"[System.Windows.Forms.Screen]::PrimaryScreen.Bounds | "
"ForEach-Object { \"$($_.Width)x$($_.Height)\" }"],
capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip() or "unknown"
except Exception:
return "unknown"
def check_browser_fingerprint_consistency(profile_dir: "Path") -> list[str]:
"""
Verify that the managed Firefox user.js is internally consistent and
doesn't mix prefs that would expose the browser as fingerprint-hardened
while still advertising a normal user-agent.
Returns a list of warning strings (empty = consistent).
"""
from pathlib import Path
user_js = Path(profile_dir) / "user.js"
if not user_js.is_file():
return ["user.js not found — profile not yet initialized"]
try:
content = user_js.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return [f"Cannot read user.js: {e}"]
warnings: list[str] = []
def _has(pref: str, value: str) -> bool:
return f'"{pref}", {value}' in content
# 1. RFP + custom UA is a contradiction (RFP overrides UA)
if _has("privacy.resistFingerprinting", "true") and 'general.useragent.override' in content:
warnings.append(
"RFP + UA override conflict: privacy.resistFingerprinting=true overrides "
"general.useragent.override — the spoofed UA is ignored"
)
# 2. proxy not set but force_proxy claimed
if _has("network.proxy.type", "1"):
if 'network.proxy.http",' not in content:
warnings.append("proxy.type=1 but network.proxy.http is missing — browsers will error")
# 3. FPI on + third-party cookies allowed = inconsistent privacy posture
if _has("privacy.firstparty.isolate", "true") and _has("network.cookie.cookieBehavior", "0"):
warnings.append(
"FPI enabled but cookieBehavior=0 (accept all) — cookies are isolated but not blocked"
)
# 4. sanitizeOnShutdown without clearing history = incomplete wipe
if _has("privacy.sanitize.sanitizeOnShutdown", "true"):
if not _has("privacy.clearOnShutdown.history", "true"):
warnings.append(
"sanitizeOnShutdown=true but clearOnShutdown.history not set — "
"history may survive session"
)
# 5. WebRTC peerconnection disabled is good — flag if missing
if not _has("media.peerconnection.enabled", "false"):
warnings.append("WebRTC not disabled in profile — real IP can leak via STUN")
# 6. network.trr.mode=5 (DoH off) is expected when using proxy DNS
if not _has("network.trr.mode", "5"):
warnings.append(
"network.trr.mode is not 5 — Firefox may use its own DoH resolver, "
"bypassing the proxy chain DNS path"
)
return warnings
def audit_device() -> FingerprintAudit:
"""Collect OS-level identifiers and consistency notes."""
import getpass
import os
from pathlib import Path
host = get_computer_name()
guid = get_machine_guid()
user = getpass.getuser()
nics = list_nics()
macs = [f"{n.name}: {n.mac}" for n in nics]
os_ = _get_os_info()
tz_ = _get_timezone()
res_ = _get_screen_resolution()
lines: list[str] = [
"── OS / Identity ──────────────────────────────────",
f" OS : {os_}",
f" Computer name: {host or ''}",
f" Username : {user}",
f" MachineGuid : " + (f"{guid[:8]}{guid[-4:]}" if len(guid) > 12 else guid or ""),
f" Timezone : {tz_}",
f" Screen res : {res_}",
"",
"── Network adapters ───────────────────────────────",
]
lines.extend([f"{m}" for m in macs[:10]] or [" • (none detected)"])
if len(macs) > 10:
lines.append(f" … and {len(macs) - 10} more")
lines += [
"",
"── Browser fingerprint note ───────────────────────",
" Canvas, WebGL, font metrics, audio context, and screen dimensions",
" are NOT changed at the OS level by this app.",
" → Use 'blend_windows_chrome' persona OR 'hardened' mode to control",
" what the browser reports to sites.",
" → RFP (Resist Fingerprinting) makes Firefox stand out as hardened.",
" → Blend persona spoofs UA/platform to look like a normal Windows Chrome.",
"",
"── Recommendations ────────────────────────────────",
]
# Quick consistency checks — WebRTC IP-handling policy (Chrome / Edge)
# Accept either: DWORD DefaultWebRtcIpHandlingPolicy==3
# or REG_SZ WebRtcIPHandling=="disable_non_proxied_udp"
webrtc_ok = False
for hive_root in (_WEBRTC_CHROME, _WEBRTC_EDGE):
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, hive_root, 0, winreg.KEY_QUERY_VALUE
) as k:
try:
v = int(winreg.QueryValueEx(k, _WEBRTC_VALUE)[0])
if v == _WEBRTC_DISABLE:
webrtc_ok = True
break
except OSError:
pass
try:
v_str = str(winreg.QueryValueEx(k, _WEBRTC_VALUE_STR)[0])
if v_str == _WEBRTC_DISABLE_STR:
webrtc_ok = True
break
except OSError:
pass
except OSError:
continue
if webrtc_ok:
lines.append(" ✓ Chrome/Edge WebRTC IP-handling policy is set (disable_non_proxied_udp)")
else:
lines.append(" ⚠ Chrome/Edge WebRTC policy not set — enable in Privacy tab")
# Check IPv6
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"Get-NetAdapterBinding -ComponentID ms_tcpip6 | Where-Object { $_.Enabled } | Measure-Object | Select-Object -ExpandProperty Count"],
capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
count = int((r.stdout or "0").strip() or "0")
if count > 0:
lines.append(f" ⚠ IPv6 active on {count} adapter(s) — disable in Privacy tab for full v4-only chain")
else:
lines.append(" ✓ IPv6 disabled on all adapters")
except Exception:
lines.append(" ? IPv6 status unknown")
return FingerprintAudit(
lines=lines,
hostname=host,
machine_guid=guid,
username=user,
macs=[n.mac for n in nics],
)

View File

@@ -0,0 +1,191 @@
"""
Windows Firewall enforcement — kill-switch style.
When engaged:
- Default outbound policy → BLOCK (all profiles)
- Allow rules for: GOST, NordVPN stack, this app, loopback, DHCP
- Everything else is denied outbound — apps that don't go through the
local proxy simply can't reach the internet.
When disengaged:
- Remove our rules, restore default outbound → ALLOW
"""
from __future__ import annotations
import ctypes
import logging
import subprocess
import sys
from pathlib import Path
from .paths import gost_exe_path
from .vpn_detect import expand_vpn_executables
log = logging.getLogger(__name__)
RULE_PREFIX = "PCM_"
def is_admin() -> bool:
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
except Exception:
return False
def request_admin_relaunch() -> bool:
"""Re-launch the current process elevated. Returns True if launched, False on error."""
if is_admin():
return False
try:
exe = sys.executable
args = " ".join(f'"{a}"' for a in sys.argv)
ret = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", exe, args, None, 1
)
return ret > 32
except Exception:
return False
def _run(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
args, capture_output=True, text=True, timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except FileNotFoundError as exc:
return subprocess.CompletedProcess(args, 127, "", str(exc))
def _add_rule(name: str, **kw: str) -> bool:
full = RULE_PREFIX + name
args = ["netsh", "advfirewall", "firewall", "add", "rule", f"name={full}"]
for k, v in kw.items():
args.append(f"{k}={v}")
r = _run(args)
ok = r.returncode == 0
if not ok:
log.warning("Firewall rule %s failed: %s", full, (r.stdout or "") + (r.stderr or ""))
return ok
def _delete_rules() -> None:
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=out"])
lines = (r.stdout or "").splitlines()
names: set[str] = set()
for ln in lines:
if ln.startswith("Rule Name:"):
n = ln.split(":", 1)[1].strip()
if n.startswith(RULE_PREFIX):
names.add(n)
for n in names:
_run(["netsh", "advfirewall", "firewall", "delete", "rule", f"name={n}"])
log.info("Deleted %d PCM firewall rules", len(names))
def _set_outbound_policy(action: str) -> None:
"""action = 'blockinbound,blockoutbound' or 'blockinbound,allowoutbound'."""
for profile in ("domainprofile", "privateprofile", "publicprofile"):
_run(["netsh", "advfirewall", "set", profile, "firewallpolicy", action])
def _resolve_self_exe() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve()
return Path(sys.executable).resolve()
def engage(gost_path: Path | None = None) -> tuple[bool, str]:
"""Activate kill-switch firewall. Returns (success, message)."""
if sys.platform != "win32":
return False, "Kill-switch is Windows-only; skipped on macOS."
if not is_admin():
return False, "Need admin privileges for firewall enforcement."
gost = gost_path or gost_exe_path()
self_exe = _resolve_self_exe()
vpn_exes = expand_vpn_executables()
_delete_rules()
# Allow loopback (apps → GOST on 127.0.0.1)
_add_rule("Loopback", dir="out", action="allow",
remoteip="127.0.0.0/8", protocol="any")
# Allow GOST outbound (it connects to the proxy hops)
_add_rule("GOST", dir="out", action="allow",
program=f'"{gost}"', protocol="any")
# Allow this application outbound (fetches proxy lists, validates)
_add_rule("Self", dir="out", action="allow",
program=f'"{self_exe}"', protocol="any")
# If running from python.exe, also allow pythonw.exe
if self_exe.name.lower() in ("python.exe", "pythonw.exe"):
for sibling in ("python.exe", "pythonw.exe"):
p = self_exe.with_name(sibling)
if p.is_file():
_add_rule(f"Python_{sibling}", dir="out", action="allow",
program=f'"{p}"', protocol="any")
# Allow VPN client executables (Nord, WireGuard, OpenVPN, etc.)
for i, vpath in enumerate(vpn_exes):
_add_rule(f"VPN_{i}", dir="out", action="allow",
program=f'"{vpath}"', protocol="any")
# Allow DHCP (or you lose your adapter)
_add_rule("DHCP", dir="out", action="allow",
protocol="udp", remoteport="67,68")
# Allow DNS (GOST needs to resolve proxy hostnames)
_add_rule("DNS_UDP", dir="out", action="allow",
protocol="udp", remoteport="53")
_add_rule("DNS_TCP", dir="out", action="allow",
protocol="tcp", remoteport="53")
# Set default outbound to BLOCK
_set_outbound_policy("blockinbound,blockoutbound")
log.info("Firewall kill-switch engaged. %d VPN exes whitelisted.", len(vpn_exes))
return True, f"Kill-switch ON. {len(vpn_exes)} VPN client(s) whitelisted."
def disengage() -> tuple[bool, str]:
"""Remove kill-switch, restore normal outbound."""
if sys.platform != "win32":
return True, "Kill-switch is Windows-only; nothing to remove on macOS."
if not is_admin():
return False, "Need admin to remove firewall rules."
_set_outbound_policy("blockinbound,allowoutbound")
_delete_rules()
log.info("Firewall kill-switch disengaged.")
return True, "Kill-switch OFF. Normal outbound restored."
def is_engaged() -> bool:
"""Quick check: are our rules present?"""
if sys.platform != "win32":
return False
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_PREFIX}GOST", "dir=out"])
return RULE_PREFIX in (r.stdout or "")
def emergency_disengage() -> None:
"""Best-effort kill-switch removal for atexit/signal handlers.
Unlike ``disengage()``, this never raises and does not require prior
admin check — it simply tries, logs the outcome, and returns. Safe to
call from atexit or signal handlers where exceptions must not propagate.
No-op when our rules are not present (avoids touching firewall policy on
every process exit).
"""
if not is_engaged():
return
try:
_set_outbound_policy("blockinbound,allowoutbound")
_delete_rules()
log.info("emergency_disengage: firewall rules removed.")
except Exception as exc: # noqa: BLE001
log.warning("emergency_disengage failed: %s", exc)

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
import hashlib
import io
import logging
import platform
import shutil
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
from typing import Any
import httpx
from .paths import app_data_dir, gost_exe_path
log = logging.getLogger(__name__)
GOST_VERSION = "3.2.6"
GOST_RELEASE_ZIP = (
"https://github.com/go-gost/gost/releases/download/v3.2.6/"
"gost_3.2.6_windows_amd64.zip"
)
# Pinned SHA256 of the release zip. Update this constant when bumping GOST_RELEASE_ZIP.
# Verified 2026-05-21 against https://github.com/go-gost/gost/releases/download/v3.2.6/
GOST_RELEASE_ZIP_SHA256 = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b"
# Pinned SHA256 of the extracted ``gost.exe`` binary inside the v3.2.6 zip.
# Recorded the first time the zip is unpacked and verified on every reuse, so
# a tampered or partially-overwritten exe on disk forces a clean re-download.
GOST_EXE_SHA256_FILE = "gost.sha256"
_DARWIN_ARCHIVE_SHA256 = {
"arm64": "e54f6c22e81c00650adfbbb23317c74a4dca9b9b73fa28cfa150f5559cc3ff2e",
"x86_64": "0892485bd94e37b67a1f1d0d2372ed12d7dc0f1bc763d56177a0c0ee734855e6",
"amd64": "0892485bd94e37b67a1f1d0d2372ed12d7dc0f1bc763d56177a0c0ee734855e6",
}
def _release_asset() -> tuple[str, str, str]:
"""Return (url, archive_sha256, binary_name) for this platform."""
if sys.platform == "darwin":
machine = platform.machine().lower()
asset_arch = "arm64" if machine == "arm64" else "amd64"
digest = _DARWIN_ARCHIVE_SHA256["arm64" if asset_arch == "arm64" else "amd64"]
name = f"gost_{GOST_VERSION}_darwin_{asset_arch}.tar.gz"
return f"https://github.com/go-gost/gost/releases/download/v{GOST_VERSION}/{name}", digest, "gost"
return GOST_RELEASE_ZIP, GOST_RELEASE_ZIP_SHA256, "gost.exe"
def _add_defender_exclusion(path: Path) -> None:
"""Add Windows Defender exclusion so GOST is not quarantined."""
if sys.platform != "win32":
return
try:
# ExclusionPath covers gost.exe under this folder; avoid invalid combined params on older builds.
folder = str(path.parent).replace("'", "''")
subprocess.run(
[
"powershell", "-NoProfile", "-NonInteractive", "-Command",
f"Add-MpPreference -ExclusionPath '{folder}' -ErrorAction SilentlyContinue",
],
capture_output=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
log.info("Defender exclusion added for %s", path.parent)
except Exception as exc:
log.warning("Defender exclusion failed (non-fatal): %s", exc)
def _verify_zip_sha256(data: bytes, expected: str) -> None:
"""Raise RuntimeError if SHA256 of *data* does not match *expected* (hex)."""
actual = hashlib.sha256(data).hexdigest().lower()
if actual != expected.lower():
raise RuntimeError(
f"GOST zip SHA256 mismatch — possible supply-chain attack!\n"
f" expected: {expected.lower()}\n"
f" actual: {actual}\n"
"Delete the downloaded zip and retry, or update GOST_RELEASE_ZIP_SHA256."
)
log.info("GOST zip SHA256 OK: %s", actual)
def _hash_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(64 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def _read_pinned_exe_hash(exe: Path) -> str | None:
sidecar = exe.with_suffix(exe.suffix + ".sha256")
try:
return sidecar.read_text(encoding="utf-8").strip().lower() or None
except OSError:
return None
def _write_pinned_exe_hash(exe: Path, digest: str) -> None:
sidecar = exe.with_suffix(exe.suffix + ".sha256")
try:
sidecar.write_text(digest, encoding="utf-8")
except OSError as exc:
log.warning("Could not write gost.exe hash sidecar: %s", exc)
def _bundled_gost_paths() -> tuple[Path | None, Path | None]:
"""Return (exe_path, sha_path) inside the PyInstaller bundle, or (None, None)."""
base = getattr(sys, "_MEIPASS", None)
if not base:
# Source checkout — look next to the package
base = str(Path(__file__).resolve().parent.parent)
binary_name = "gost.exe" if sys.platform == "win32" else "gost"
bundle_exe = Path(base) / "proxy_chain_manager" / "_bundled" / binary_name
bundle_sha = bundle_exe.with_suffix(bundle_exe.suffix + ".sha256")
if bundle_exe.is_file():
return bundle_exe, (bundle_sha if bundle_sha.is_file() else None)
return None, None
def _install_from_bundle(exe: Path) -> bool:
"""Copy the bundled gost.exe into *exe* and pin its hash. Returns True
on success. Verifies the bundle hash if a sidecar shipped with it."""
src, sha_src = _bundled_gost_paths()
if not src:
return False
try:
exe.parent.mkdir(parents=True, exist_ok=True)
_add_defender_exclusion(exe)
shutil.copy2(src, exe)
_add_defender_exclusion(exe)
digest = _hash_file(exe)
if sha_src:
expected = sha_src.read_text(encoding="utf-8").strip().lower()
if expected and expected != digest:
exe.unlink(missing_ok=True)
log.warning(
"Bundled gost.exe SHA mismatch (bundle=%s actual=%s) — "
"falling back to network download.", expected, digest,
)
return False
_write_pinned_exe_hash(exe, digest)
log.info("GOST installed from bundle at %s", exe)
return True
except Exception as exc: # noqa: BLE001
log.warning("Bundle install failed: %s", exc)
return False
def ensure_gost(target: Path | None = None) -> Path:
exe = target or gost_exe_path()
if exe.is_file() and exe.stat().st_size > 10_000:
# Re-verify the on-disk binary against the sidecar pin so a tampered
# exe cannot persist across launches.
pinned = _read_pinned_exe_hash(exe)
if pinned:
actual = _hash_file(exe)
if actual == pinned:
return exe
log.warning(
"gost.exe SHA256 mismatch on reuse — re-installing.\n"
" pinned: %s\n actual: %s",
pinned, actual,
)
try:
exe.unlink()
except OSError as exc:
log.warning("Could not remove tampered gost.exe: %s", exc)
else:
# First reuse after older versions: record the current hash so the
# next run can verify. Mark as trusted-on-first-use only when zip
# SHA verification has already happened earlier in the session.
_write_pinned_exe_hash(exe, _hash_file(exe))
return exe
# Prefer the bundled binary so the first run works fully offline.
if _install_from_bundle(exe):
return exe
exe.parent.mkdir(parents=True, exist_ok=True)
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
_add_defender_exclusion(exe)
url, archive_sha, binary_name = _release_asset()
log.info("Downloading GOST %s", url)
with httpx.Client(timeout=120.0, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data = r.content
_verify_zip_sha256(data, archive_sha)
if url.endswith(".zip"):
if len(data) < 64 or data[:2] != b"PK":
raise RuntimeError("Downloaded GOST zip looks invalid (not a zip).")
with zipfile.ZipFile(io.BytesIO(data), "r") as z:
names = [n for n in z.namelist() if n.lower().endswith(binary_name)]
if not names:
raise RuntimeError(f"{binary_name} not found in release zip")
with z.open(names[0]) as src, open(exe, "wb") as dst:
shutil.copyfileobj(src, dst)
else:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
members = [m for m in tar.getmembers() if Path(m.name).name == binary_name and m.isfile()]
if not members:
raise RuntimeError(f"{binary_name} not found in release tarball")
src = tar.extractfile(members[0])
if src is None:
raise RuntimeError(f"{binary_name} could not be extracted")
with src, open(exe, "wb") as dst:
shutil.copyfileobj(src, dst)
try:
exe.chmod(0o755)
except OSError:
pass
# Add exclusion again after write in case Defender scanned during extraction
_add_defender_exclusion(exe)
# Pin the freshly-extracted exe so subsequent launches can re-verify it.
_write_pinned_exe_hash(exe, _hash_file(exe))
log.info("GOST installed at %s", exe)
return exe
def build_gost_cmd(gost: Path, listen_http: str, forwards: list[str]) -> list[str]:
args = [str(gost), "-L", f"http://{listen_http}"]
for f in forwards:
args.extend(["-F", f])
return args
def gost_log_path() -> Path:
return app_data_dir() / "gost.log"
def popen_no_window(args: list[str]) -> subprocess.Popen:
# stderr → rolling log file (not a pipe — pipes deadlock if unread; files never block).
# stdout is also captured to the same file since GOST v3 mixes output channels.
cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
log_path = gost_log_path()
try:
_rotate_gost_log(log_path)
stderr_dest: Any = open(log_path, "ab") # noqa: WPS515 — intentional long-lived file handle
except OSError:
stderr_dest = subprocess.DEVNULL
return subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=stderr_dest,
stderr=stderr_dest,
creationflags=cr,
)
def _rotate_gost_log(path: Path, max_bytes: int = 512 * 1024) -> None:
"""Keep gost.log under max_bytes by truncating the oldest half when it grows too large."""
try:
if path.is_file() and path.stat().st_size > max_bytes:
data = path.read_bytes()
path.write_bytes(data[len(data) // 2:])
except OSError:
pass
def read_gost_log_tail(lines: int = 40) -> str:
"""Return the last N lines of gost.log for display in the UI."""
try:
text = gost_log_path().read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:])
except OSError:
return "(gost.log not found)"
def terminate_process(proc: subprocess.Popen | None) -> None:
if proc is None:
return
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=5)
except Exception as exc:
log.debug("GOST terminate failed (%s) — escalating to kill.", exc)
try:
proc.kill()
except Exception as kill_exc:
log.warning("GOST kill also failed: %s", kill_exc)

View File

@@ -0,0 +1,41 @@
"""Fast parallel proxy checks for the GUI (per-hop callbacks)."""
from __future__ import annotations
import asyncio
from typing import Callable
from .validator import _check_one
# High concurrency for interactive chain tests
GUI_VALIDATE_CONCURRENCY = 48
async def validate_each_parallel(
urls: list[str],
check_url: str,
concurrency: int,
timeout_seconds: float,
on_result: Callable[[str, bool], None],
) -> None:
if not urls:
return
sem = asyncio.Semaphore(max(1, concurrency))
async def one(u: str) -> None:
async with sem:
ok = await _check_one(u, check_url, timeout_seconds)
on_result(u, ok)
await asyncio.gather(*(one(u) for u in urls))
def run_validate_each_sync(
urls: list[str],
check_url: str,
timeout_seconds: float,
on_result: Callable[[str, bool], None],
concurrency: int = GUI_VALIDATE_CONCURRENCY,
) -> None:
asyncio.run(
validate_each_parallel(urls, check_url, concurrency, timeout_seconds, on_result)
)

View File

@@ -0,0 +1,313 @@
"""Consolidated live leak audit.
Used by the Privacy tab "Run audit" button. Probes every surface that can
deanonymize the host even when the chain is healthy:
• IP — exit IP through chain vs direct IP (subnet leak)
• DNS — system resolvers, whether they're public
• IPv6 binding — adapters with IPv6 active
• WPAD/PAC — leftover AutoConfigURL or AutoDetect
• Policy locks — Group Policy proxy keys that beat our settings
• PerUser flag — Windows Server ProxySettingsPerUser
• LAN broadcast — LLMNR / NetBIOS / mDNS status
• VPN — adapter presence
• WebRTC — Chrome/Edge policy presence
• System proxy — what registry says we're set to
• TLS exit — quick categorisation hint (datacenter / residential)
Each check has its own short timeout so a slow probe never blocks the rest.
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
import winreg
from dataclasses import dataclass, field
from .dns_leak import get_system_dns_servers
from .firewall import is_admin
from .privacy_lan import lan_status
from .sysproxy import detect_policy_overrides, is_system_proxy_set
from .validator import check_chain_exit_ip, get_direct_ip
from .vpn_detect import detect_vpn
log = logging.getLogger(__name__)
@dataclass
class AuditFinding:
name: str
ok: bool
value: str
note: str = ""
@dataclass
class AuditReport:
findings: list[AuditFinding] = field(default_factory=list)
overall_ok: bool = True
def add(self, name: str, ok: bool, value: str, note: str = "") -> None:
self.findings.append(AuditFinding(name, ok, value, note))
if not ok:
self.overall_ok = False
def _check_ipv6_active() -> tuple[bool, str]:
"""True/'string' if any 'Up' adapter has IPv6 binding enabled.
PowerShell 3.0+ path; falls back to ipconfig parsing (locale-tolerant
enough — looks for hex colons).
"""
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"Get-NetAdapterBinding -ComponentID ms_tcpip6 | "
"Where-Object { $_.Enabled } | "
"Select-Object -ExpandProperty Name"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
names = [n.strip() for n in (r.stdout or "").splitlines() if n.strip()]
if names:
return True, ", ".join(names[:3]) + (f" +{len(names)-3}" if len(names) > 3 else "")
# Empty stdout could mean PS3 cmdlet missing — try ipconfig fallback.
if r.returncode != 0:
raise RuntimeError("PS3 net cmdlets unavailable")
return False, "no adapters bound to IPv6"
except Exception:
try:
r = subprocess.run(
["ipconfig"], capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
has_v6 = any(
"IPv6" in ln and ":" in ln.split(":", 1)[1]
for ln in (r.stdout or "").splitlines()
)
return has_v6, ("IPv6 address present" if has_v6 else "no IPv6 address")
except Exception as e:
return False, f"unknown ({e})"
def _check_wpad() -> tuple[bool, str]:
path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
autoconf = ""
autodet = 0
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_QUERY_VALUE
) as key:
try:
autoconf = str(winreg.QueryValueEx(key, "AutoConfigURL")[0] or "")
except OSError:
pass
try:
autodet = int(winreg.QueryValueEx(key, "AutoDetect")[0])
except OSError:
autodet = 0
except OSError:
pass
leaking = bool(autoconf) or autodet == 1
if leaking:
bits = []
if autoconf:
bits.append(f"AutoConfigURL={autoconf}")
if autodet:
bits.append(f"AutoDetect={autodet}")
return False, ", ".join(bits)
return True, "no WPAD/PAC override"
def _check_per_user_flag() -> tuple[bool, str]:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings",
0, winreg.KEY_QUERY_VALUE,
) as key:
v = int(winreg.QueryValueEx(key, "ProxySettingsPerUser")[0])
if v == 0:
return False, "ProxySettingsPerUser=0 (HKCU IGNORED)"
return True, "ProxySettingsPerUser=1"
except OSError:
return True, "unset (default → HKCU honored)"
def _check_webrtc_policy() -> tuple[bool, str]:
"""OK means the policy IS set (browsers won't leak non-proxied UDP)."""
paths = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
)
_DWORD_DISABLE = 3 # disable_non_proxied_udp
_STR_KEY = "WebRtcIPHandling"
_STR_DISABLE = "disable_non_proxied_udp"
found = []
for p in paths:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, p, 0, winreg.KEY_QUERY_VALUE
) as key:
ok = False
try:
v = int(winreg.QueryValueEx(key, "DefaultWebRtcIpHandlingPolicy")[0])
if v == _DWORD_DISABLE:
ok = True
except OSError:
pass
try:
v_str = str(winreg.QueryValueEx(key, _STR_KEY)[0]).strip().lower()
if v_str == _STR_DISABLE:
ok = True
except OSError:
pass
if ok:
found.append(p.split("\\")[-1])
except OSError:
continue
if found:
return True, "policy set: " + ", ".join(found)
return False, "no WebRTC policy — Chrome/Edge may leak local IPs"
def _categorize_exit(ip: str | None) -> str:
"""Cheap categorization hint based on common RIR / ASN heuristics.
No external lookup — just shape-based hints. Real classification can be
added later via offline GeoIP DBs.
"""
if not ip:
return "unknown"
try:
a = int(ip.split(".")[0])
except Exception:
return "non-IPv4"
if a in (10,) or ip.startswith(("192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.")):
return "PRIVATE — chain broken"
if a == 127:
return "LOOPBACK — chain broken"
return "public"
async def run_audit(
listen_proxy: str,
ip_check_url: str,
timeout_seconds: float = 10.0,
) -> AuditReport:
"""Run every check in parallel where safe; return structured report."""
rep = AuditReport()
chain_running = is_system_proxy_set()
# Network probes in parallel
direct_task = asyncio.create_task(get_direct_ip(ip_check_url, timeout_seconds))
chain_task = (
asyncio.create_task(
check_chain_exit_ip(listen_proxy, ip_check_url, timeout_seconds, chain_hops=1)
)
if chain_running else None
)
direct_ip = await direct_task
exit_ip = await chain_task if chain_task else None
if direct_ip:
rep.add("Direct IP (no chain)", True, direct_ip, "")
else:
rep.add("Direct IP (no chain)", False, "unreachable", "no internet?")
if chain_running:
if exit_ip:
cat = _categorize_exit(exit_ip)
same = bool(direct_ip and exit_ip == direct_ip)
rep.add(
"Chain exit IP",
not same and cat == "public",
f"{exit_ip} ({cat})",
"matches direct IP — chain not forwarding" if same else "",
)
else:
rep.add("Chain exit IP", False, "unreachable through chain", "")
else:
rep.add("Chain exit IP", True, "chain not running", "skipped")
# DNS resolvers
dns_servers = get_system_dns_servers()
private_prefixes = ("127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.")
public_dns = [d for d in dns_servers if not d.startswith(private_prefixes)]
if not dns_servers:
rep.add("DNS resolvers", True, "none reported (DHCP)", "")
elif public_dns:
rep.add(
"DNS resolvers",
False,
", ".join(dns_servers),
f"{len(public_dns)} public — DNS may bypass chain",
)
else:
rep.add("DNS resolvers", True, ", ".join(dns_servers), "all private/local")
# IPv6 binding
v6_on, v6_msg = _check_ipv6_active()
rep.add("IPv6 binding", not v6_on, v6_msg, "IPv6 active = potential leak past v4 proxies" if v6_on else "")
# WPAD
wpad_ok, wpad_msg = _check_wpad()
rep.add("WPAD / PAC", wpad_ok, wpad_msg)
# Per-User flag
pu_ok, pu_msg = _check_per_user_flag()
rep.add("ProxySettingsPerUser", pu_ok, pu_msg,
"needs Admin fix on this box" if not pu_ok else "")
# Policy locks
pol = detect_policy_overrides()
if pol:
rep.add("Group Policy proxy locks", False, f"{len(pol)} entries",
"policy keys override our proxy")
else:
rep.add("Group Policy proxy locks", True, "none")
# 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"]
rep.add(
"LAN broadcast (LLMNR/NetBIOS/mDNS)",
lan_clean,
f"LLMNR={lan['llmnr']} NetBIOS={lan['netbios']} mDNS={lan['mdns']}",
"hostname leaks to local network" if not lan_clean else "",
)
# VPN
vpn = detect_vpn()
rep.add(
"VPN adapter",
True,
f"{vpn.label}" + (f" ({vpn.adapter})" if vpn.adapter else ""),
"informational",
)
# WebRTC policy
rtc_ok, rtc_msg = _check_webrtc_policy()
rep.add("Browser WebRTC policy", 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 "",
)
return rep
def run_audit_sync(
listen_proxy: str,
ip_check_url: str,
timeout_seconds: float = 10.0,
) -> AuditReport:
return asyncio.run(run_audit(listen_proxy, ip_check_url, timeout_seconds))

View File

@@ -0,0 +1,64 @@
"""VPN-aware chain leak detection."""
from __future__ import annotations
def is_same_subnet(ip_a: str | None, ip_b: str | None, prefix_len: int = 16) -> bool:
"""True if two IPv4 addresses share the same /prefix_len subnet."""
if not ip_a or not ip_b:
return False
try:
a_parts = [int(x) for x in ip_a.split(".")]
b_parts = [int(x) for x in ip_b.split(".")]
if len(a_parts) != 4 or len(b_parts) != 4:
return False
def to_int(parts: list[int]) -> int:
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (to_int(a_parts) & mask) == (to_int(b_parts) & mask)
except Exception:
return False
def is_chain_leak(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> bool:
"""True when the chain is not forwarding (traffic still looks like direct/VPN exit).
With VPN: compare /16 — VPN IPs rotate but stay in-provider ranges.
Without VPN: exact IP match only (avoid false positives on same ISP /16).
**Fail-closed**: when ``real_ip`` is unknown we cannot prove the chain is
safe, so we conservatively return ``True``. The service loop applies a
short warm-up grace period before this verdict kicks in so a transient
direct-IP lookup failure doesn't cause perpetual rotation.
"""
if not exit_ip:
return True
if not real_ip:
# Fail-closed: README promises "fail closed" when trust dies.
return True
if exit_ip == real_ip:
return True
if vpn_active:
return is_same_subnet(exit_ip, real_ip)
return False
def leak_reason(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> str:
if not exit_ip:
return "exit IP unreachable"
if not real_ip:
return (
"direct IP unknown — cannot prove the chain is forwarding "
"(fail-closed). Check internet connectivity and ip_check_url."
)
if exit_ip == real_ip:
if vpn_active:
return f"exit {exit_ip} equals VPN/direct IP (chain not forwarding)"
return f"exit {exit_ip} equals your real IP (no anonymization)"
if vpn_active and is_same_subnet(exit_ip, real_ip):
return (
f"exit {exit_ip} shares /16 with direct {real_ip} "
"(likely exiting via VPN tunnel, not proxy chain)"
)
return "ok"

View File

@@ -0,0 +1,158 @@
"""Randomize and restore NIC MAC addresses (Admin required)."""
from __future__ import annotations
import logging
import random
import re
import subprocess
from dataclasses import dataclass
from .firewall import is_admin
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
_MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
@dataclass
class NicMac:
name: str
mac: str
description: str = ""
def _run(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
def list_nics() -> list[NicMac]:
"""Physical/up adapters with current MAC.
Falls back to ``wmic nic`` for hosts without PowerShell 3.0 (Server 2008 R2).
"""
compat = _win_probe()
if not compat.has_net_cmdlets:
return _list_nics_wmic()
script = (
"Get-NetAdapter | Where-Object { $_.Status -ne 'Disabled' } | "
"Select-Object Name, MacAddress, InterfaceDescription | "
"ConvertTo-Json -Compress"
)
code, out, _ = _run(["powershell", "-NoProfile", "-Command", script])
if code != 0 or not out.strip():
return _list_nics_wmic()
import json
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
rows = data if isinstance(data, list) else [data]
nics: list[NicMac] = []
for row in rows:
if not isinstance(row, dict):
continue
name = str(row.get("Name", "")).strip()
mac = str(row.get("MacAddress", "")).strip().replace("-", ":")
desc = str(row.get("InterfaceDescription", "")).strip()
if name and mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=name, mac=mac, description=desc))
return nics
def random_mac() -> str:
"""Locally administered unicast MAC."""
b = [random.randint(0, 255) for _ in range(6)]
b[0] = (b[0] | 0x02) & 0xFE
return ":".join(f"{x:02X}" for x in b)
def _list_nics_wmic() -> list[NicMac]:
"""Server 2008 R2 / PowerShell 2.0 fallback via wmic."""
code, out, _ = _run([
"wmic", "nic", "where", "NetEnabled=true",
"get", "NetConnectionID,MACAddress,Name", "/format:list",
])
if code != 0 or not out:
return []
nics: list[NicMac] = []
cur = {"id": "", "mac": "", "name": ""}
for ln in out.splitlines():
s = ln.strip()
if not s:
if cur["id"] and cur["mac"]:
mac = cur["mac"].replace("-", ":")
if mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=cur["id"], mac=mac, description=cur["name"]))
cur = {"id": "", "mac": "", "name": ""}
continue
if s.startswith("NetConnectionID="):
cur["id"] = s.split("=", 1)[1].strip()
elif s.startswith("MACAddress="):
cur["mac"] = s.split("=", 1)[1].strip()
elif s.startswith("Name="):
cur["name"] = s.split("=", 1)[1].strip()
if cur["id"] and cur["mac"]:
mac = cur["mac"].replace("-", ":")
if mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=cur["id"], mac=mac, description=cur["name"]))
return nics
def set_mac(adapter: str, mac: str) -> tuple[bool, str]:
if not is_admin():
return False, "Administrator required to change MAC."
if not _win_probe().has_net_cmdlets:
return False, (
"MAC change requires PowerShell 3.0+ (Windows 8 / Server 2012+). "
"Detected legacy PowerShell — feature unavailable on this host."
)
mac = mac.replace("-", ":").upper()
if not _MAC_RE.match(mac.replace(":", "-")):
return False, f"Invalid MAC: {mac}"
ps = (
f'$a = Get-NetAdapter -Name "{adapter}" -ErrorAction Stop; '
f'Set-NetAdapter -Name $a.Name -MacAddress "{mac}" -Confirm:$false'
)
code, _, err = _run(["powershell", "-NoProfile", "-Command", ps])
if code != 0:
return False, err or "Set-NetAdapter failed"
log.info("MAC set %s%s", adapter, mac)
return True, f"{adapter}{mac}"
def spoof_all_physical(snapshot: dict[str, str] | None = None) -> tuple[dict[str, str], list[str]]:
"""Randomize MAC on non-virtual adapters. Returns (original_map, log lines)."""
originals: dict[str, str] = dict(snapshot or {})
logs: list[str] = []
skip_hints = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth", "wan miniport")
for nic in list_nics():
low = (nic.description + nic.name).lower()
if any(h in low for h in skip_hints):
continue
if nic.name not in originals:
originals[nic.name] = nic.mac
new_mac = random_mac()
ok, msg = set_mac(nic.name, new_mac)
logs.append(msg if ok else f"{nic.name}: {msg}")
return originals, logs
def restore_macs(originals: dict[str, str]) -> list[str]:
logs: list[str] = []
for name, mac in originals.items():
ok, msg = set_mac(name, mac)
logs.append(msg if ok else f"{name}: {msg}")
return logs

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
def app_data_dir() -> Path:
if sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
elif os.environ.get("LOCALAPPDATA"):
base = Path(os.environ["LOCALAPPDATA"])
else:
base = Path.home() / "AppData" / "Local"
d = base / "ProxyChainManager"
d.mkdir(parents=True, exist_ok=True)
return d
def gost_exe_path() -> Path:
suffix = ".exe" if sys.platform == "win32" else ""
return app_data_dir() / f"gost{suffix}"

View File

@@ -0,0 +1,106 @@
"""Fetch proxy lists for UI picker and shared pool building."""
from __future__ import annotations
import asyncio
import logging
import random
from concurrent.futures import ThreadPoolExecutor
from typing import Callable
from .config import Settings
from .fetcher import fetch_proxy_json, normalize_entries
from .validator import validate_proxies
log = logging.getLogger(__name__)
_POOL_EXEC = ThreadPoolExecutor(max_workers=6, thread_name_prefix="pool_ops")
PICKER_DISPLAY_CAP = 200
class ProxySourceCache:
"""Cache full source lists; each UI pull shows a new random batch."""
def __init__(self) -> None:
self._full: list[str] = []
self.total_cached: int = 0
def clear(self) -> None:
self._full.clear()
self.total_cached = 0
@property
def loaded(self) -> bool:
return bool(self._full)
def load(self, sources: list[str], prefer_elite: bool,
on_status: Callable[[str], None] | None = None) -> int:
self._full = fetch_raw_proxies(sources, prefer_elite, on_status=on_status)
self.total_cached = len(self._full)
return self.total_cached
def random_batch(self, cap: int = PICKER_DISPLAY_CAP) -> list[str]:
if not self._full:
return []
k = min(cap, len(self._full))
return random.sample(self._full, k)
def fetch_raw_proxies(
sources: list[str],
prefer_elite: bool,
on_status: Callable[[str], None] | None = None,
) -> list[str]:
"""Blocking fetch from all JSON sources; deduped, not validated."""
if not sources:
sources = list(Settings().sources)
seen: set[str] = set()
out: list[str] = []
for url in sources:
if on_status:
on_status(f"Fetching {url[:70]}")
try:
rows = fetch_proxy_json(url, timeout=45.0)
entries = normalize_entries(rows, prefer_elite)
if on_status:
on_status(f"{len(entries)} proxies")
for u in entries:
if u not in seen:
seen.add(u)
out.append(u)
except Exception as e:
log.warning("fetch_raw_proxies %s: %s", url[:60], e)
if on_status:
on_status(f" → error: {e!s}")
return out
async def validate_proxy_subset(
urls: list[str],
settings: Settings,
target: int = 0,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
if not urls:
return []
sample = list(urls)
random.shuffle(sample)
if len(sample) > settings.max_candidates:
sample = sample[: settings.max_candidates]
tgt = target or settings.min_pool_size
return await validate_proxies(
sample,
settings.ip_check_url,
settings.validation_concurrency,
settings.validation_timeout_seconds,
on_progress=on_progress,
target=tgt,
)
def run_validate_sync(
urls: list[str],
settings: Settings,
target: int = 0,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
return asyncio.run(validate_proxy_subset(urls, settings, target=target, on_progress=on_progress))

View File

@@ -0,0 +1,291 @@
"""LAN-scope hostname leak lockdown.
Windows by default broadcasts the local hostname on every adapter via:
- LLMNR (UDP 5355) — link-local multicast name resolution
- NetBIOS over TCP/IP — broadcast name registration on the subnet
- mDNS (UDP 5353) — Bonjour-style name advertising on newer Windows
Anyone on the same Wi-Fi / VLAN can grab the hostname and tie it to the MAC
address. Killing these is a paranoid-mode no-brainer.
All three are reversible. We snapshot original state on engage and restore on
disengage.
"""
from __future__ import annotations
import json
import logging
import subprocess
from dataclasses import dataclass, field
import winreg
from .firewall import is_admin
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
_LLMNR_POLICY_PATH = r"SOFTWARE\Policies\Microsoft\Windows NT\DNSClient"
_LLMNR_VALUE = "EnableMulticast"
_MDNS_DNSCLIENT = r"SYSTEM\CurrentControlSet\Services\Dnscache\Parameters"
_MDNS_VALUE = "EnableMDNS"
@dataclass
class LanSnapshot:
netbios_per_interface: dict[str, int] = field(default_factory=dict)
llmnr_present: bool = False
llmnr_prev_value: int | None = None
mdns_present: bool = False
mdns_prev_value: int | None = None
def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
def _list_netbios_via_wmic() -> dict[str, int]:
"""Per-NIC NetBIOS setting via wmic (works back to Server 2008 R2).
Returns: { settings_id : tcpip_netbios_options }
0 = use DHCP, 1 = enabled, 2 = disabled
"""
code, out, _ = _run([
"wmic", "nicconfig", "where", "IPEnabled=true",
"get", "SettingID,TcpipNetbiosOptions", "/format:list",
])
if code != 0 or not out:
return {}
settings: dict[str, int] = {}
cur_id = ""
cur_val: int | None = None
for raw in out.splitlines():
line = raw.strip()
if not line:
if cur_id and cur_val is not None:
settings[cur_id] = cur_val
cur_id, cur_val = "", None
continue
if line.startswith("SettingID="):
cur_id = line.split("=", 1)[1].strip()
elif line.startswith("TcpipNetbiosOptions="):
try:
cur_val = int(line.split("=", 1)[1].strip())
except ValueError:
cur_val = None
if cur_id and cur_val is not None:
settings[cur_id] = cur_val
return settings
def _set_netbios_via_wmic(settings_id: str, mode: int) -> bool:
"""mode: 0=DHCP, 1=enabled, 2=disabled."""
code, _, _ = _run([
"wmic", "nicconfig", "where",
f"SettingID='{settings_id}'",
"call", "SetTcpipNetbios", str(mode),
])
return code == 0
def _llmnr_read() -> tuple[bool, int | None]:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, _LLMNR_VALUE)
return True, int(val)
except OSError:
return False, None
def _llmnr_write(value: int) -> bool:
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, _LLMNR_VALUE, 0, winreg.REG_DWORD, value)
return True
except OSError:
return False
def _llmnr_delete() -> None:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, _LLMNR_VALUE)
except OSError:
pass
def _mdns_read() -> tuple[bool, int | None]:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, _MDNS_VALUE)
return True, int(val)
except OSError:
return False, None
def _mdns_write(value: int) -> bool:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, _MDNS_VALUE, 0, winreg.REG_DWORD, value)
return True
except OSError:
return False
def _restart_dnscache() -> None:
"""Bounce the DNS Client service so the LLMNR/mDNS toggles take effect.
Server hardening baselines sometimes mark this service "denied" — failure
is logged and ignored; the change still applies on next reboot.
"""
_run(["net", "stop", "Dnscache", "/y"], timeout=20)
_run(["net", "start", "Dnscache"], timeout=20)
def lan_status() -> dict[str, str]:
"""Human-readable current state. Returned even when not Admin."""
nb = _list_netbios_via_wmic()
if nb:
disabled = sum(1 for v in nb.values() if v == 2)
nb_summary = f"{disabled}/{len(nb)} NICs with NetBIOS disabled"
else:
nb_summary = "unavailable"
llmnr_present, llmnr_v = _llmnr_read()
if not llmnr_present:
llmnr_summary = "default (ON)"
else:
llmnr_summary = "OFF" if llmnr_v == 0 else f"ON (policy={llmnr_v})"
mdns_present, mdns_v = _mdns_read()
if not mdns_present:
mdns_summary = "default (ON on Win10 1903+)"
else:
mdns_summary = "OFF" if mdns_v == 0 else f"ON (EnableMDNS={mdns_v})"
return {
"netbios": nb_summary,
"llmnr": llmnr_summary,
"mdns": mdns_summary,
}
def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
"""Disable LLMNR / NetBIOS / mDNS. Returns (snapshot for restore, log lines).
snapshot is None when the operation was refused (not Admin, etc.).
"""
logs: list[str] = []
if not is_admin():
return None, ["LAN privacy lockdown skipped — needs Administrator."]
snap = LanSnapshot()
snap.netbios_per_interface = _list_netbios_via_wmic()
snap.llmnr_present, snap.llmnr_prev_value = _llmnr_read()
snap.mdns_present, snap.mdns_prev_value = _mdns_read()
if snap.netbios_per_interface:
changed = 0
for sid in snap.netbios_per_interface:
if _set_netbios_via_wmic(sid, 2):
changed += 1
logs.append(f"NetBIOS over TCP/IP disabled on {changed}/{len(snap.netbios_per_interface)} NICs.")
else:
logs.append("NetBIOS: could not enumerate adapters (wmic missing?).")
if _llmnr_write(0):
logs.append("LLMNR (UDP 5355) disabled via DNSClient policy.")
else:
logs.append("LLMNR disable failed (policy write rejected).")
if _mdns_write(0):
logs.append("mDNS (UDP 5353) disabled via Dnscache parameters.")
else:
logs.append("mDNS disable failed (Dnscache parameters not writable).")
_restart_dnscache()
logs.append("DNS Client service bounced — LAN lockdown active.")
return snap, logs
def restore_lan(snap: LanSnapshot | None) -> list[str]:
"""Roll back to the snapshot captured by engage_lan_lockdown."""
if snap is None or not is_admin():
return []
logs: list[str] = []
if snap.netbios_per_interface:
restored = 0
for sid, prev in snap.netbios_per_interface.items():
if _set_netbios_via_wmic(sid, prev):
restored += 1
logs.append(f"NetBIOS restored on {restored}/{len(snap.netbios_per_interface)} NICs.")
if snap.llmnr_present and snap.llmnr_prev_value is not None:
_llmnr_write(snap.llmnr_prev_value)
logs.append(f"LLMNR policy restored to {snap.llmnr_prev_value}.")
else:
_llmnr_delete()
logs.append("LLMNR policy removed (was default).")
if snap.mdns_present and snap.mdns_prev_value is not None:
_mdns_write(snap.mdns_prev_value)
logs.append(f"mDNS restored (EnableMDNS={snap.mdns_prev_value}).")
else:
# If mDNS key was absent originally, delete the value we created.
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, _MDNS_VALUE)
logs.append("mDNS toggle removed (was default).")
except OSError:
pass
_restart_dnscache()
return logs
def snapshot_to_json(snap: LanSnapshot | None) -> str:
if snap is None:
return ""
return json.dumps({
"netbios": snap.netbios_per_interface,
"llmnr_present": snap.llmnr_present,
"llmnr_prev_value": snap.llmnr_prev_value,
"mdns_present": snap.mdns_present,
"mdns_prev_value": snap.mdns_prev_value,
})
def snapshot_from_json(raw: str) -> LanSnapshot | None:
if not raw:
return None
try:
d = json.loads(raw)
return LanSnapshot(
netbios_per_interface={k: int(v) for k, v in (d.get("netbios") or {}).items()},
llmnr_present=bool(d.get("llmnr_present")),
llmnr_prev_value=d.get("llmnr_prev_value"),
mdns_present=bool(d.get("mdns_present")),
mdns_prev_value=d.get("mdns_prev_value"),
)
except Exception:
return None

View File

@@ -0,0 +1,201 @@
"""Modal dialog: random batch from cached sources, multi-select."""
from __future__ import annotations
import threading
from typing import Any, Callable
import customtkinter as ctk
from .config import Settings, redact_proxy_url
from .pool_ops import PICKER_DISPLAY_CAP, ProxySourceCache
BG = "#0d0d1a"
CARD = "#12122a"
PANEL = "#1a1a3e"
ACCENT = "#1e3a8a"
ACCENT2 = "#2563eb"
GREEN = "#00e676"
RED = "#ff1744"
YELLOW = "#ffab00"
DIM = "#4a5568"
TEXT = "#e2e8f0"
TEXT2 = "#94a3b8"
FONT = "Segoe UI"
_UI_CHUNK = 50
# One cache per app session — avoids re-downloading 800+ URLs every open
_SESSION_CACHE = ProxySourceCache()
def show_proxy_picker(
parent: Any,
settings: Settings,
prefer_elite: bool,
on_done: Callable[[list[str], str], None],
) -> None:
"""``on_done(urls, mode)`` where mode is ``append`` or ``replace``."""
win = ctk.CTkToplevel(parent)
win.title("Browse proxy lists")
win.geometry("700x500")
win.configure(fg_color=BG)
win.transient(parent)
win.grab_set()
status = ctk.CTkLabel(
win,
text=f"Pull loads sources once, then shows {PICKER_DISPLAY_CAP} random proxies per batch.",
font=(FONT, 11),
text_color=TEXT2,
)
status.pack(fill="x", padx=12, pady=(10, 4))
filter_var = ctk.StringVar(value="")
filt_row = ctk.CTkFrame(win, fg_color="transparent")
filt_row.pack(fill="x", padx=12, pady=4)
ctk.CTkLabel(filt_row, text="Filter:", font=(FONT, 10), text_color=TEXT2).pack(side="left")
ctk.CTkEntry(
filt_row, textvariable=filter_var, width=180, height=28,
fg_color=BG, border_color=ACCENT,
).pack(side="left", padx=6)
proto_var = ctk.StringVar(value="all")
for label, val in [("All", "all"), ("HTTP", "http"), ("SOCKS5", "socks5")]:
ctk.CTkRadioButton(
filt_row, text=label, variable=proto_var, value=val,
font=(FONT, 10), fg_color=ACCENT2, text_color=TEXT,
).pack(side="left", padx=4)
scroll = ctk.CTkScrollableFrame(win, fg_color=CARD, height=300,
scrollbar_button_color=ACCENT)
scroll.pack(fill="both", expand=True, padx=12, pady=6)
display_proxies: list[str] = []
check_vars: dict[str, ctk.BooleanVar] = {}
_build_gen = [0]
def _filtered_urls() -> list[str]:
q = filter_var.get().strip().lower()
pv = proto_var.get()
out: list[str] = []
for u in display_proxies:
if pv == "http" and not u.startswith("http://"):
continue
if pv == "socks5" and not u.startswith("socks5://"):
continue
if q and q not in u.lower():
continue
out.append(u)
return out
def _rebuild_list() -> None:
_build_gen[0] += 1
gen = _build_gen[0]
for w in scroll.winfo_children():
w.destroy()
urls = _filtered_urls()
if not urls:
status.configure(
text="No proxies match filter — pull a batch or change filter.",
text_color=TEXT2,
)
return
def _chunk(start: int) -> None:
if gen != _build_gen[0]:
return
end = min(start + _UI_CHUNK, len(urls))
for u in urls[start:end]:
if u not in check_vars:
check_vars[u] = ctk.BooleanVar(value=False)
fr = ctk.CTkFrame(scroll, fg_color=PANEL, corner_radius=4)
fr.pack(fill="x", pady=1)
ctk.CTkCheckBox(
fr, text=redact_proxy_url(u), variable=check_vars[u],
font=("Consolas", 10), fg_color=ACCENT2, text_color=TEXT,
).pack(anchor="w", padx=8, pady=2)
if end < len(urls):
win.after(1, lambda: _chunk(end))
else:
cached = _SESSION_CACHE.total_cached
status.configure(
text=f"Showing {len(urls)} of batch ({cached} cached in memory). Select → OK.",
text_color=GREEN,
)
_chunk(0)
def _select_all(on: bool) -> None:
for u in _filtered_urls():
if u in check_vars:
check_vars[u].set(on)
def _pull(force_reload: bool = False) -> None:
pull_btn.configure(state="disabled")
reload_btn.configure(state="disabled")
status.configure(text="Loading sources…", text_color=YELLOW)
def work() -> None:
def stat(msg: str) -> None:
win.after(0, lambda m=msg: status.configure(text=m, text_color=TEXT2))
if force_reload:
_SESSION_CACHE.clear()
if not _SESSION_CACHE.loaded:
_SESSION_CACHE.load(list(settings.sources), prefer_elite, on_status=stat)
batch = _SESSION_CACHE.random_batch(PICKER_DISPLAY_CAP)
def finish() -> None:
nonlocal display_proxies
display_proxies = batch
check_vars.clear()
for u in batch:
check_vars[u] = ctk.BooleanVar(value=False)
pull_btn.configure(state="normal")
reload_btn.configure(state="normal")
_rebuild_list()
win.after(0, finish)
threading.Thread(target=work, daemon=True).start()
def _selected() -> list[str]:
return [u for u, v in check_vars.items() if v.get()]
def _ok(mode: str) -> None:
sel = _selected()
if not sel:
status.configure(text="Select at least one proxy.", text_color=RED)
return
win.grab_release()
win.destroy()
on_done(sel, mode)
btn_row = ctk.CTkFrame(win, fg_color="transparent")
btn_row.pack(fill="x", padx=12, pady=(4, 12))
pull_btn = ctk.CTkButton(
btn_row, text="↻ Random batch", command=lambda: _pull(False),
width=120, fg_color=ACCENT, hover_color=ACCENT2,
)
pull_btn.pack(side="left", padx=2)
reload_btn = ctk.CTkButton(
btn_row, text="Reload sources", command=lambda: _pull(True),
width=110, fg_color=DIM, hover_color=ACCENT,
)
reload_btn.pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Select all", command=lambda: _select_all(True),
width=72, fg_color=DIM).pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Clear", command=lambda: _select_all(False),
width=52, fg_color=DIM).pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Cancel",
command=lambda: (win.grab_release(), win.destroy()),
width=64, fg_color="#7f1d1d").pack(side="right", padx=2)
ctk.CTkButton(btn_row, text="Add to chain", command=lambda: _ok("append"),
width=100, fg_color=GREEN, hover_color=ACCENT2).pack(side="right", padx=2)
ctk.CTkButton(btn_row, text="Replace chain", command=lambda: _ok("replace"),
width=100, fg_color=ACCENT2).pack(side="right", padx=2)
filter_var.trace_add("write", lambda *_: _rebuild_list())
proto_var.trace_add("write", lambda *_: _rebuild_list())
_pull(False)

View File

@@ -0,0 +1,160 @@
"""Windows DPAPI-based secret storage.
Provides ``encrypt(plaintext: str) -> str`` and ``decrypt(token: str) -> str``
backed by ``CryptProtectData`` / ``CryptUnprotectData``.
Why DPAPI:
- Built into Windows; no third-party crypto dependency.
- Key material is tied to the *current user account* (or the machine, when
``CRYPTPROTECT_LOCAL_MACHINE`` is used) — no passphrase to remember.
- Data encrypted on disk cannot be decrypted by another user / machine.
Token format: ``DPAPI:v1:<base64-blob>``
Plain strings that don't start with ``DPAPI:`` are returned unchanged by
``decrypt()`` so the loader can safely walk old plaintext files and re-encrypt
them on the next save (migration is free).
Falls back gracefully on non-Windows or when DPAPI is unavailable: ``encrypt``
returns the original string verbatim (logged once).
"""
from __future__ import annotations
import base64
import ctypes
import logging
import sys
from ctypes import wintypes
log = logging.getLogger(__name__)
_TOKEN_PREFIX = "DPAPI:v1:"
# ── Windows API binding ──────────────────────────────────────────────────────
class _DATA_BLOB(ctypes.Structure):
_fields_ = [
("cbData", wintypes.DWORD),
("pbData", ctypes.POINTER(ctypes.c_char)),
]
def _blob(data: bytes) -> _DATA_BLOB:
buf = ctypes.create_string_buffer(data, len(data))
return _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
def _is_windows() -> bool:
return sys.platform == "win32"
_warned_unavailable = False
def _warn_unavailable(reason: str) -> None:
global _warned_unavailable
if not _warned_unavailable:
log.warning("DPAPI secret store unavailable: %s — secrets will be stored in plaintext.", reason)
_warned_unavailable = True
# ── Public API ───────────────────────────────────────────────────────────────
def is_available() -> bool:
"""Return True if the current process can use DPAPI."""
if not _is_windows():
return False
try:
ctypes.windll.crypt32 # noqa: B018 — just confirm the DLL is importable
return True
except Exception: # noqa: BLE001
return False
def encrypt(plaintext: str) -> str:
"""Encrypt *plaintext* with current-user DPAPI.
Returns ``DPAPI:v1:<base64>`` on success. Returns the original plaintext
on any failure (callers can therefore always trust the return value as the
string to persist).
"""
if not plaintext:
return ""
# Already encrypted — pass through.
if plaintext.startswith(_TOKEN_PREFIX):
return plaintext
if not is_available():
_warn_unavailable("not running on Windows")
return plaintext
try:
data = plaintext.encode("utf-8")
in_blob = _blob(data)
out_blob = _DATA_BLOB()
ok = ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(in_blob),
"ProxyGodSecret", # description (ignored by us)
None, # optional entropy
None, # reserved
None, # prompt struct
0, # flags
ctypes.byref(out_blob),
)
if not ok:
err = ctypes.get_last_error()
_warn_unavailable(f"CryptProtectData failed (err={err})")
return plaintext
try:
enc = ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
return _TOKEN_PREFIX + base64.b64encode(enc).decode("ascii")
except Exception as exc: # noqa: BLE001
_warn_unavailable(f"unexpected error: {exc}")
return plaintext
def decrypt(token: str) -> str:
"""Decrypt a token produced by :func:`encrypt`.
Plain strings (not starting with the ``DPAPI:`` prefix) are returned
verbatim — this is intentional, it lets the loader transparently read
legacy plaintext files and re-encrypt on save.
"""
if not token:
return ""
if not token.startswith(_TOKEN_PREFIX):
return token # legacy plaintext
if not is_available():
_warn_unavailable("not running on Windows — cannot decrypt")
return ""
try:
b64 = token[len(_TOKEN_PREFIX):]
data = base64.b64decode(b64.encode("ascii"))
in_blob = _blob(data)
out_blob = _DATA_BLOB()
ok = ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(in_blob),
None, # ppszDataDescr
None, # entropy
None, # reserved
None, # prompt struct
0, # flags
ctypes.byref(out_blob),
)
if not ok:
err = ctypes.get_last_error()
log.warning("CryptUnprotectData failed (err=%s) — secret stayed encrypted.", err)
return ""
try:
plain = ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
return plain.decode("utf-8", errors="replace")
except Exception as exc: # noqa: BLE001
log.warning("DPAPI decrypt error: %s", exc)
return ""
def is_encrypted(token: str) -> bool:
return bool(token) and token.startswith(_TOKEN_PREFIX)

View File

@@ -0,0 +1,949 @@
from __future__ import annotations
import atexit
import asyncio
import logging
import random
import signal
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from .config import (
Settings,
load_settings,
normalize_proxy_url,
redact_proxy_url,
save_settings,
)
from .dns_leak import flush_dns_cache
from .fetcher import fetch_proxy_json, normalize_entries
from .fingerprint import (
apply_webrtc_hardening,
disable_ipv6_on_adapters,
enable_ipv6_on_adapters,
get_computer_name,
random_hostname,
set_computer_name,
)
from .firewall import disengage as fw_disengage, emergency_disengage as fw_emergency_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
from .leak_detect import is_chain_leak, leak_reason
from .mac_spoof import restore_macs, spoof_all_physical
from .privacy_lan import (
LanSnapshot,
engage_lan_lockdown,
restore_lan,
)
from .telemetry_kill import (
TelemetrySnapshot,
engage_telemetry_kill,
restore_telemetry,
)
from .sysproxy import (
clear_system_proxy,
detect_policy_overrides,
diagnose_system_proxy,
is_system_proxy_set,
set_system_proxy,
)
from .validator import (
check_chain_exit_ip,
check_https_tunnel,
get_direct_ip,
validate_proxies,
)
from .vpn_detect import VpnStatus, detect_vpn
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
Notify = Callable[[dict[str, Any]], None]
# Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
# Back-compat for tests
from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool:
"""Open and close a TCP socket; True if the handshake completes within *timeout*."""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=timeout,
)
writer.close()
try:
await writer.wait_closed()
except Exception: # noqa: BLE001
pass
return True
except Exception: # noqa: BLE001
return False
class ChainService:
"""Background rotating proxy chain using GOST."""
def __init__(self, notify: Notify) -> None:
self._notify = notify
self._stop = threading.Event()
self._force_rotate = threading.Event()
self._thread: threading.Thread | None = None
self._proc = None
self._settings_lock = threading.Lock()
self._settings = load_settings()
self._current_chain: list[str] = []
# Sticky-exit: while time.monotonic() < self._sticky_until, leak-based
# auto-rotation is skipped so signup flows keep the same egress IP.
self._sticky_until: float = 0.0
# Shuffle-and-drain pool state — never repeat a proxy within a cycle
self._available: list[str] = [] # proxies not yet used this cycle
self._used: set[str] = set() # proxies used this cycle
# Per-session blacklist: proxies that crashed GOST immediately
self._blacklist: set[str] = set()
self._vpn: VpnStatus = VpnStatus()
self._mac_originals: dict[str, str] = {}
self._hostname_original: str | None = None
self._ipv6_adapters: list[str] = []
self._webrtc_was_applied: bool = False
self._lan_snap: LanSnapshot | None = None
self._telemetry_snap: TelemetrySnapshot | None = None
# Register emergency firewall disengage so a crash can't leave the
# kill-switch permanently engaged.
atexit.register(fw_emergency_disengage)
for _sig in (signal.SIGTERM, signal.SIGINT):
try:
signal.signal(_sig, self._signal_handler)
except (OSError, ValueError):
pass # not on main thread or unsupported on this platform
def _signal_handler(self, signum: int, frame: Any) -> None:
"""Received SIGTERM/SIGINT — stop cleanly and ensure firewall is disengaged."""
log.warning("Signal %s received — stopping ChainService.", signum)
self._stop.set()
terminate_process(self._proc)
fw_emergency_disengage()
def _manual_exit_url(self) -> str | None:
with self._settings_lock:
u = normalize_proxy_url(self._settings.manual_exit_proxy)
return u if u else None
@property
def settings(self) -> Settings:
with self._settings_lock:
return self._settings
@property
def current_chain(self) -> list[str]:
return list(self._current_chain)
def update_settings(self, s: Settings) -> None:
with self._settings_lock:
self._settings = s
save_settings(s)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
compat = _win_probe()
self._notify({"type": "log", "text": f"Host: {compat.summary()}"})
if not compat.has_net_cmdlets:
self._notify({
"type": "log",
"text": (
"Legacy PowerShell detected — MAC spoof / IPv6 disable / "
"hostname spoof unavailable on this host. Proxy chain + "
"kill-switch + WebRTC policy still work."
),
})
self._stop.clear()
self._thread = threading.Thread(
target=self._run_thread, name="ChainService", daemon=True
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
terminate_process(self._proc)
self._proc = None
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
log.warning("ChainService thread did not exit in 15s — forcing firewall disengage.")
fw_emergency_disengage()
self._teardown_network()
self._notify({"type": "state", "running": False})
def rotate_now(self) -> None:
self._force_rotate.set()
def set_sticky(self, seconds: float) -> None:
"""Hold current exit for ``seconds`` (suppresses leak-driven rotation).
Manual rotation still works. Pass 0 to clear sticky mode immediately.
"""
if seconds <= 0:
self._sticky_until = 0.0
return
self._sticky_until = time.monotonic() + float(seconds)
def sticky_remaining(self) -> float:
"""Seconds remaining on sticky-exit hold (0.0 if not sticky)."""
rem = self._sticky_until - time.monotonic()
return rem if rem > 0 else 0.0
# ─────────────────────────────────────────────────────────────────────────
def _teardown_network(self) -> None:
clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."})
self._restore_privacy()
with self._settings_lock:
ks_enabled = self._settings.kill_switch_enabled
if is_admin() and ks_enabled:
ok, msg = fw_disengage()
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": False})
def _apply_privacy(self) -> None:
s = self._settings
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."})
if s.spoof_hostname_enabled and is_admin():
self._hostname_original = get_computer_name()
new_name = random_hostname()
ok, msg = set_computer_name(new_name)
self._notify({"type": "log", "text": f"Hostname: {msg}"})
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():
self._ipv6_adapters, logs = disable_ipv6_on_adapters()
for ln in logs:
self._notify({"type": "log", "text": ln})
elif s.disable_ipv6_while_active:
self._notify({"type": "log", "text": "IPv6 disable enabled but not Admin — skipped."})
if s.harden_webrtc_enabled and is_admin():
ok, msg = apply_webrtc_hardening(True)
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."})
if s.lan_lockdown_enabled and is_admin():
snap, logs = engage_lan_lockdown()
self._lan_snap = snap
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."})
if s.telemetry_kill_enabled and is_admin():
tsnap, tlogs = engage_telemetry_kill()
self._telemetry_snap = tsnap
for ln in tlogs[:8]:
self._notify({"type": "log", "text": f"Telemetry: {ln}"})
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."})
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():
ok, msg = set_computer_name(self._hostname_original)
self._notify({"type": "log", "text": f"Hostname restore: {msg}"})
self._hostname_original = None
if self._ipv6_adapters:
for ln in enable_ipv6_on_adapters(self._ipv6_adapters):
self._notify({"type": "log", "text": ln})
self._ipv6_adapters.clear()
if self._webrtc_was_applied and is_admin():
apply_webrtc_hardening(False)
self._webrtc_was_applied = False
self._notify({"type": "log", "text": "WebRTC policy restored."})
if self._lan_snap is not None and is_admin():
for ln in restore_lan(self._lan_snap):
self._notify({"type": "log", "text": f"LAN: {ln}"})
self._lan_snap = None
if self._telemetry_snap is not None and is_admin():
for ln in restore_telemetry(self._telemetry_snap)[:8]:
self._notify({"type": "log", "text": f"Telemetry: {ln}"})
self._telemetry_snap = None
def _run_thread(self) -> None:
try:
asyncio.run(self._async_main())
except Exception:
log.exception("service thread failed")
self._notify({"type": "log", "text": "Fatal error in service thread (see log)."})
finally:
terminate_process(self._proc)
self._proc = None
self._teardown_network()
self._notify({"type": "state", "running": False})
# ─────────────────────────────────────────────────────────────────────────
# MAIN ASYNC LOOP
# ─────────────────────────────────────────────────────────────────────────
async def _async_main(self) -> None:
self._notify({"type": "state", "running": True})
log.info(
"Service start: chain_length=%d mode=%s sources=%d pinned=%s kill_switch=%s",
self._settings.chain_length,
self._settings.obfuscation_mode,
len(self._settings.sources),
self._settings.use_pinned_chain,
self._settings.kill_switch_enabled,
)
# ── GOST setup ───────────────────────────────────────────────────────
try:
gost = ensure_gost()
self._notify({"type": "log", "text": f"GOST ready: {gost}"})
except Exception as e:
self._notify({"type": "log", "text": f"GOST setup failed: {e}"})
return
if gost.stat().st_size < 10_000:
self._notify({"type": "log", "text": "GOST appears quarantined. Re-downloading..."})
gost.unlink(missing_ok=True)
try:
gost = ensure_gost()
except Exception as e:
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
return
# ── VPN + direct IP ───────────────────────────────────────────────────
self._vpn = detect_vpn()
mode = "VPN-aware (/16)" if self._vpn.active else "strict (exact IP)"
self._notify({
"type": "vpn",
"active": self._vpn.active,
"label": self._vpn.label,
"adapter": self._vpn.adapter,
"leak_mode": mode,
})
self._notify({
"type": "log",
"text": (
f"VPN: {self._vpn.label}"
+ (f" ({self._vpn.adapter})" if self._vpn.adapter else "")
+ f" — leak check: {mode}"
),
})
# Warm-up: retry the direct-IP lookup a few times so a transient
# network blip during startup doesn't put us in permanent fail-closed.
real_ip = None
for attempt in range(3):
real_ip = await get_direct_ip(self._settings.ip_check_url)
if real_ip:
break
if attempt < 2:
await asyncio.sleep(2.0)
if real_ip:
self._notify({"type": "real_ip", "ip": real_ip})
label = "direct/VPN IP" if self._vpn.active else "your real IP"
self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"})
else:
# Leak detection now FAILS CLOSED on unknown real_ip — any chain
# whose exit IP can't be compared against a baseline will be
# treated as a leak and rotated. Make the operator aware up front.
self._notify({"type": "log", "text": (
"Could not determine direct IP after 3 attempts — leak detection "
"will FAIL CLOSED (rotate on any chain whose exit IP cannot be "
"verified). Fix internet / ip_check_url to restore."
)})
self._apply_privacy()
# ── Firewall kill-switch ──────────────────────────────────────────────
if self._settings.kill_switch_enabled:
if is_admin():
ok, msg = fw_engage(gost)
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": ok})
else:
self._notify({"type": "log", "text": "Kill-switch skipped (not Admin)."})
self._notify({"type": "firewall", "engaged": False})
else:
self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
self._notify({"type": "firewall", "engaged": False})
log.debug(
"Listener %s | refresh=%ds health=%ds max_candidates=%d concurrency=%d",
self._settings.listen_addr(),
int(self._settings.full_refresh_seconds),
int(self._settings.health_check_seconds),
int(self._settings.max_candidates),
int(self._settings.validation_concurrency),
)
# ── Main rotation loop ────────────────────────────────────────────────
full_pool: list[str] = []
last_full = 0.0
rotation_num = 0
while not self._stop.is_set():
now = time.monotonic()
need_refresh = (
not full_pool
or now - last_full >= float(self._settings.full_refresh_seconds)
)
log.debug(
"Loop tick: full_pool=%d available=%d used=%d blacklist=%d need_refresh=%s age=%.0fs",
len(full_pool),
len(self._available),
len(self._used),
len(self._blacklist),
need_refresh,
(now - last_full) if full_pool else 0.0,
)
# Pinned (manual) chain mode — skip pool management
if self._settings.use_pinned_chain and not self._settings.pinned_chain:
self._notify({
"type": "log",
"text": (
"Pinned chain enabled but list is empty — add hops in Chain Builder "
"or disable 'Use pinned chain'."
),
})
if self._settings.use_pinned_chain and self._settings.pinned_chain:
chain = list(self._settings.pinned_chain)
# Optional per-hop TCP probe so a known-dead pinned chain
# doesn't loop forever on the 10 s retry below.
if self._settings.validate_pinned_on_start:
from urllib.parse import urlparse as _urlparse
dead = []
for hop in chain:
try:
p = _urlparse(hop if "://" in hop else "http://" + hop)
if p.hostname and p.port:
if not await _probe_tcp(p.hostname, int(p.port), 6.0):
dead.append(hop)
except Exception: # noqa: BLE001
dead.append(hop)
if dead:
self._notify({"type": "log", "text": (
f"Pinned chain: {len(dead)}/{len(chain)} hop(s) failed TCP "
f"probe — proceeding anyway (will retry on failure)."
)})
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
log.info("Pinned chain run: %d hops", len(chain))
ok = await self._run_chain(gost, chain, real_ip)
if not ok:
await self._sleep_interruptible(10)
if self._stop.is_set():
break
continue
# Fixed exit only: hop count = 1 → chain is only the manual exit (no pool)
mex = self._manual_exit_url()
if mex and not self._settings.use_pinned_chain and self._settings.chain_length == 1:
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
ok = await self._run_chain(gost, [mex], real_ip)
if not ok:
await self._sleep_interruptible(15)
if self._stop.is_set():
break
continue
# ── Pool refresh ─────────────────────────────────────────────────
if need_refresh:
self._notify({"type": "phase", "phase": "fetch"})
log.info("Pool refresh: fetching %d source(s)…", len(self._settings.sources))
full_pool = await self._build_pool()
last_full = time.monotonic()
self._available = list(full_pool)
random.shuffle(self._available)
self._used.clear()
self._notify({"type": "pool", "count": len(full_pool)})
if not full_pool:
self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
await self._sleep_interruptible(60)
last_full = 0.0
continue
# ── Pick next chain (shuffle-and-drain, no repeats per cycle) ────
chain = self._pick_chain()
if not chain:
# Pool exhausted for this cycle — reshuffle and restart
self._notify({"type": "log", "text": "Pool cycle complete — reshuffling for next round."})
self._available = list(full_pool)
random.shuffle(self._available)
self._used.clear()
chain = self._pick_chain()
if not chain:
await self._sleep_interruptible(15)
continue
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
log.info(
"Auto chain #%d: %d hops | obfuscation=%s | pool_remain=%d",
rotation_num,
len(chain),
self._settings.obfuscation_mode,
len(self._available),
)
await self._run_chain(gost, chain, real_ip)
if self._stop.is_set():
break
log.info("Main loop exit (stop requested or fatal).")
terminate_process(self._proc)
self._proc = None
# ─────────────────────────────────────────────────────────────────────────
# CHAIN RUNNER
# ─────────────────────────────────────────────────────────────────────────
async def _run_chain(
self, gost: Any, chain: list[str], real_ip: str | None
) -> bool:
"""Start GOST with chain, verify exit IP, monitor until rotation/stop.
Returns True if chain ran successfully, False if it immediately failed."""
self._current_chain = list(chain)
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
self._notify({"type": "phase", "phase": "gost_start"})
if self._settings.flush_dns_on_rotate:
ok, msg = flush_dns_cache()
if ok:
log.debug("DNS cache flushed before chain run")
# Per-rotation MAC re-randomization (only if mac_spoof is also on, since
# without spoof there's no original snapshot we own to mutate).
if (
self._settings.mac_rotate_on_chain_rotate
and self._settings.mac_spoof_enabled
and is_admin()
and self._mac_originals
):
_, logs = spoof_all_physical(self._mac_originals)
for ln in logs:
self._notify({"type": "log", "text": f"MAC rotate: {ln}"})
listen = self._settings.listen_addr()
cmd = build_gost_cmd(gost, listen, chain)
self._notify({
"type": "log",
"text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
+ "".join(self._short(h) for h in chain),
})
red = " | ".join(redact_proxy_url(h) for h in chain)
log.debug("GOST listen=http://%s | forwards (redacted): %s", listen, red)
log.debug("GOST argv: %s … (%d args)", cmd[0], len(cmd))
terminate_process(self._proc)
self._proc = popen_no_window(cmd)
self._notify({"type": "log", "text": "GOST started — warming up (2s)…"})
for _ in range(4):
if self._stop.is_set():
terminate_process(self._proc)
self._proc = None
return False
await asyncio.sleep(0.5)
if self._proc.poll() is not None:
# GOST died immediately — blacklist pool proxies (never blacklist user fixed exit)
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
tail = read_gost_log_tail(20)
if tail.strip():
for ln in tail.splitlines()[-8:]:
if ln.strip():
self._notify({"type": "log", "text": f" GOST> {ln.rstrip()}"})
return False
local_proxy = f"http://{listen}"
timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
self._notify({"type": "phase", "phase": "verify_chain"})
self._notify({
"type": "log",
"text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…",
})
t0 = time.monotonic()
exit_ip = await check_chain_exit_ip(
local_proxy, self._settings.ip_check_url, timeout, chain_hops=len(chain)
)
log.debug("Initial exit IP check took %.2fs → %s", time.monotonic() - t0, exit_ip or "none")
if not exit_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": "Chain IP check failed. Rotating."})
terminate_process(self._proc)
self._proc = None
return False
if is_chain_leak(exit_ip, real_ip, self._vpn.active):
reason = leak_reason(exit_ip, real_ip, self._vpn.active)
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."})
log.warning("Chain leak: exit=%s real=%s vpn=%s%s", exit_ip, real_ip, self._vpn.active, reason)
# Blacklist the whole chain so we don't reuse broken proxies
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
terminate_process(self._proc)
self._proc = None
return False
# HTTPS CONNECT must work before the chain is marked healthy.
https_ok, https_msg = await check_https_tunnel(local_proxy, timeout)
if not https_ok:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": (
"HTTPS tunnel FAILED — chain forwards plain HTTP but refuses CONNECT. "
"Browsers will time out on HTTPS sites. Rotating. " + https_msg
)})
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
terminate_process(self._proc)
self._proc = None
return False
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"})
self._notify({"type": "log", "text": f"✓ HTTPS tunnel OK — {https_msg}"})
self._notify({"type": "phase", "phase": "running"})
# Pre-flight: surface Group Policy locks (they will override us).
pol_before = detect_policy_overrides()
if pol_before:
self._notify({"type": "log", "text": (
f"Group Policy proxy lock detected ({len(pol_before)} entries) — "
"these BEAT our settings. Browsers will keep the policy proxy "
"(or DIRECT) until those keys are removed."
)})
for p in pol_before[:3]:
self._notify({"type": "log", "text": f" ! {p}"})
set_system_proxy(
self._settings.local_host,
self._settings.local_port,
self._settings.proxy_bypass,
)
applied = is_system_proxy_set()
self._notify({
"type": "log",
"text": (
f"System proxy → {self._settings.listen_addr()} "
f"(HKCU + HKLM(adm) + Connections + WinHTTP) "
f"{'OK' if applied else 'FAILED — registry write rejected'}"
),
})
# Post-flight diagnostic — every layer's actual state.
for ln in diagnose_system_proxy():
self._notify({"type": "log", "text": f" proxy: {ln}"})
# ── Health monitor loop ───────────────────────────────────────────────
hc = int(self._settings.health_check_seconds)
while not self._stop.is_set():
log.debug("Health sleep: %ds until next exit check", hc)
result = await self._wait_health_interval()
if result in ("stop", "rotate"):
log.debug("Health loop break: %s", result)
break
if self._proc is None or self._proc.poll() is not None:
self._notify({"type": "log", "text": "GOST process died — rebuilding."})
break
self._notify({"type": "log", "text": "Health check..."})
t1 = time.monotonic()
exit_ip = await check_chain_exit_ip(
local_proxy, self._settings.ip_check_url, timeout, chain_hops=len(chain)
)
log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")
if is_chain_leak(exit_ip, real_ip, self._vpn.active):
reason = leak_reason(exit_ip, real_ip, self._vpn.active)
rem = self.sticky_remaining()
if rem > 0:
self._notify({
"type": "log",
"text": (
f"Health check flagged ({reason}) — STICKY EXIT active "
f"({int(rem)}s left), skipping rotation."
),
})
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
continue
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
break
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Still healthy — Exit IP: {exit_ip}"})
terminate_process(self._proc)
self._proc = None
return True
# ─────────────────────────────────────────────────────────────────────────
# POOL MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────
@staticmethod
def _apply_mode_filter(candidates: list[str], mode: str) -> list[str]:
if mode == "http_only":
return [u for u in candidates if u.startswith("http://")]
if mode == "socks5_only":
return [u for u in candidates if u.startswith("socks5://")]
if mode == "random_mix":
out = list(candidates)
random.shuffle(out)
return out
return list(candidates)
def _pick_chain(self) -> list[str]:
"""Pick chain_length hops: optional fixed last hop + random prefix from pool."""
s = self._settings
chain = self._pick_chain_for_mode(s.obfuscation_mode)
if chain:
log.debug(
"Picked chain len=%d mode=%s available_left=%d",
len(chain),
s.obfuscation_mode,
len(self._available),
)
return chain
if s.obfuscation_mode != "auto" and not s.use_pinned_chain:
log.debug("Pick empty under mode=%s — retrying as auto", s.obfuscation_mode)
self._notify({
"type": "log",
"text": "Obfuscation filter left nothing usable — retrying this pick with ALL protocols (auto).",
})
ch2 = self._pick_chain_for_mode("auto")
if ch2:
log.debug("Auto retry picked len=%d", len(ch2))
return ch2
log.debug("Pick chain returned empty (pool exhausted?)")
return []
def _pick_chain_for_mode(self, mode: str) -> list[str]:
"""Build one chain using given mode (http_only / socks5_only / random_mix / auto)."""
s = self._settings
k = max(1, s.chain_length)
manual = self._manual_exit_url()
if manual and not s.use_pinned_chain:
mid_need = k - 1
base = [
u for u in self._available
if u not in self._blacklist and u != manual
]
candidates = self._apply_mode_filter(base, mode)
if mid_need == 0:
return [manual]
if len(candidates) < mid_need:
return []
prefix = candidates[:mid_need]
pset = set(prefix)
self._available = [u for u in self._available if u not in pset]
self._used.update(prefix)
self._used.add(manual)
return prefix + [manual]
base = [u for u in self._available if u not in self._blacklist]
candidates = self._apply_mode_filter(base, mode)
if len(candidates) < k:
return []
picked = candidates[:k]
picked_set = set(picked)
self._available = [u for u in self._available if u not in picked_set]
self._used.update(picked)
return picked
async def _build_pool(self) -> list[str]:
"""Fetch and validate proxies. Runs blocking I/O in thread pool."""
s = self._settings
loop = asyncio.get_running_loop()
log.info("_build_pool: %d source URL(s), prefer_elite=%s", len(s.sources), s.prefer_elite)
# Fetch all sources concurrently in thread pool (they are blocking)
async def _fetch_one(url: str) -> list[str]:
try:
rows = await asyncio.wait_for(
loop.run_in_executor(
_FETCH_POOL,
lambda u=url: fetch_proxy_json(u, timeout=50.0),
),
timeout=55.0,
)
entries = normalize_entries(rows, s.prefer_elite)
if s.prefer_elite and rows and not entries:
self._notify({
"type": "log",
"text": (
f"Source returned {len(rows)} proxies but none marked “elite” — all skipped. "
"Turn off “Elite proxies only” in Chain Builder if every fetch is empty."
),
})
self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
return entries
except asyncio.TimeoutError:
self._notify({"type": "log", "text": f"Fetch timed out (55s): {url[:60]}..."})
return []
except Exception as e:
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
return []
src_urls = list(s.sources) if s.sources else list(Settings().sources)
if not s.sources:
self._notify({
"type": "log",
"text": "Settings had no proxy sources — using built-in defaults (save Settings to persist).",
})
if not src_urls:
self._notify({"type": "log", "text": "No proxy source URLs configured — cannot build a pool."})
return []
results = await asyncio.gather(*(_fetch_one(u) for u in src_urls))
raw_urls: list[str] = []
for chunk in results:
raw_urls.extend(chunk)
if not raw_urls:
self._notify({
"type": "log",
"text": "No proxies fetched from any source (check network, URLs, or turn off “Elite only” if every list is filtered empty).",
})
return []
# Deduplicate, remove blacklisted, shuffle before capping
seen: set[str] = set()
unique: list[str] = []
for u in raw_urls:
if u not in seen and u not in self._blacklist:
seen.add(u)
unique.append(u)
random.shuffle(unique)
if len(unique) > s.max_candidates:
unique = unique[: s.max_candidates]
log.debug("Unique candidates after dedupe/cap: %d (max_candidates=%d)", len(unique), s.max_candidates)
self._notify({"type": "phase", "phase": "validate"})
self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})
def on_prog(done: int, total: int) -> None:
self._notify({"type": "validate_progress", "done": done, "total": total})
target = max(s.min_pool_size, s.chain_length * 3)
good = await validate_proxies(
unique,
s.ip_check_url,
s.validation_concurrency,
s.validation_timeout_seconds,
on_progress=on_prog,
target=target,
)
random.shuffle(good)
mex = normalize_proxy_url(s.manual_exit_proxy)
if mex and not s.use_pinned_chain:
self._notify({"type": "phase", "phase": "exit_check"})
self._notify({"type": "log", "text": "Validating fixed exit proxy…"})
v = await validate_proxies(
[mex],
s.ip_check_url,
1,
s.validation_timeout_seconds,
on_progress=None,
)
if v:
self._notify({"type": "log", "text": "Fixed exit proxy: OK (reachable)."})
else:
self._notify({
"type": "log",
"text": "Fixed exit proxy: validation failed — will still attempt; check URL/credentials.",
})
self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
log.info("_build_pool done: valid=%d / tested=%d", len(good), len(unique))
return good
# ─────────────────────────────────────────────────────────────────────────
# UTILITIES
# ─────────────────────────────────────────────────────────────────────────
async def _wait_health_interval(self) -> str | None:
"""Sleep for health_check_seconds. Returns 'stop', 'rotate', or None."""
total = float(self._settings.health_check_seconds)
end = time.monotonic() + total
while time.monotonic() < end:
if self._stop.is_set():
return "stop"
if self._force_rotate.is_set():
self._force_rotate.clear()
self._notify({"type": "log", "text": "Manual rotate triggered."})
if self._settings.flush_dns_on_rotate:
ok, msg = flush_dns_cache()
self._notify({"type": "log", "text": f"DNS flush: {msg}" if ok else f"DNS flush failed: {msg}"})
return "rotate"
remaining = end - time.monotonic()
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
await asyncio.sleep(1.0)
self._notify({"type": "countdown", "secs": 0})
return None
async def _sleep_interruptible(self, seconds: float) -> None:
end = time.monotonic() + seconds
while time.monotonic() < end:
if self._stop.is_set():
return
await asyncio.sleep(0.2)
@staticmethod
def _short(url: str) -> str:
url = redact_proxy_url(url)
url = (
url.replace("http://", "")
.replace("socks5://", "s5://")
.replace("socks4://", "s4://")
.replace("https://", "")
)
return url[:30] + "" if len(url) > 32 else url

View File

@@ -0,0 +1,153 @@
(function () {
"use strict";
var cfg = null;
var filledKeys = {};
var startedAt = Date.now();
var MAX_MS = 120000;
function loadConfig(cb) {
try {
var rt = typeof browser !== "undefined" ? browser : chrome;
var url = rt.runtime.getURL("autofill_config.json");
fetch(url, { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (j) { cb(j || null); })
.catch(function () { cb(null); });
} catch (e) {
cb(null);
}
}
function hostMatches(host, patterns) {
if (!patterns || !patterns.length) return true;
host = (host || "").toLowerCase();
for (var i = 0; i < patterns.length; i++) {
var p = String(patterns[i] || "").toLowerCase();
if (!p) continue;
if (p.indexOf("*") >= 0) {
var re = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
if (re.test(host)) return true;
} else if (host === p || host.endsWith("." + p)) {
return true;
}
}
return false;
}
function setValue(el, value) {
if (!el || value == null || value === "") return false;
try {
el.focus();
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
} catch (e) {
return false;
}
}
function trySelectors(selectors, value) {
if (!selectors || !value) return false;
for (var i = 0; i < selectors.length; i++) {
var nodes = document.querySelectorAll(selectors[i]);
for (var j = 0; j < nodes.length; j++) {
var el = nodes[j];
if (!el || el.disabled || el.readOnly) continue;
if (el.type === "hidden") continue;
if (setValue(el, value)) return true;
}
}
return false;
}
function runFill() {
if (!cfg || !cfg.fields) return;
if (Date.now() - startedAt > MAX_MS) return;
var host = location.hostname || "";
if (cfg.hosts && cfg.hosts.length && !hostMatches(host, cfg.hosts)) return;
var fields = cfg.fields;
var keys = Object.keys(fields);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var val = fields[key];
if (!val) continue;
if (filledKeys[key]) continue;
if (trySelectors(fields[key + "_selectors"] || defaultSelectors(key), val)) {
filledKeys[key] = true;
}
}
}
function defaultSelectors(key) {
var map = {
email: [
'input[type="email"]',
'input[name="email"]',
'input[name="Email"]',
'input[id="email"]',
'input[autocomplete="email"]',
'input[autocomplete="username"]',
'input[name="identifier"]'
],
password: [
'input[type="password"]:not([name*="Confirm"]):not([name*="confirm"]):not([name*="Again"]):not([name*="again"])',
'input[name="password"]',
'input[name="Passwd"]',
'input[id="password"]',
'input[autocomplete="new-password"]'
],
password_confirm: [
'input[name="PasswdAgain"]',
'input[name="password_confirm"]',
'input[name="confirmPassword"]',
'input[name="passwordConfirmation"]',
'input[autocomplete="new-password"]:nth-of-type(2)'
],
first_name: [
'input[name="firstName"]',
'input[name="firstname"]',
'input[name="first_name"]',
'input[autocomplete="given-name"]'
],
last_name: [
'input[name="lastName"]',
'input[name="lastname"]',
'input[name="last_name"]',
'input[autocomplete="family-name"]'
],
username: [
'input[name="username"]',
'input[name="Username"]',
'input[id="username"]',
'input[autocomplete="username"]'
]
};
return map[key] || [];
}
function boot() {
loadConfig(function (j) {
cfg = j;
if (!cfg) return;
runFill();
var obs = new MutationObserver(function () { runFill(); });
obs.observe(document.documentElement || document.body, {
childList: true,
subtree: true
});
var timer = setInterval(function () {
runFill();
if (Date.now() - startedAt > MAX_MS) clearInterval(timer);
}, 900);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,21 @@
{
"manifest_version": 2,
"name": "Proxy God Signup Autofill",
"version": "1.0.0",
"description": "Autofills signup forms from Proxy God (manual submit + captcha).",
"applications": {
"gecko": {
"id": "signup-autofill@proxygod"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": true
}
],
"permissions": ["<all_urls>"],
"web_accessible_resources": ["autofill_config.json"]
}

View File

@@ -0,0 +1,444 @@
from __future__ import annotations
import json
import secrets
import shutil
import string
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .paths import app_data_dir
_EXT_ID = "signup-autofill@proxygod"
def _ext_src_path() -> Path:
"""Locate the signup_extension dir whether running from source or frozen."""
import sys
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "proxy_chain_manager" / "signup_extension"
return Path(__file__).resolve().parent / "signup_extension"
@dataclass(frozen=True)
class SignupSitePreset:
key: str
label: str
url: str
hosts: tuple[str, ...]
extra_selectors: dict[str, list[str]] = field(default_factory=dict)
SIGNUP_PRESETS: dict[str, SignupSitePreset] = {
"google": SignupSitePreset(
key="google",
label="Google",
url="https://accounts.google.com/signup/v2/createaccount?flowName=GlifWebSignIn&flowEntry=SignUp",
hosts=("accounts.google.com", "google.com"),
extra_selectors={
"first_name": ['input[name="firstName"]'],
"last_name": ['input[name="lastName"]'],
"email": ['input[name="Username"]', 'input[type="email"]'],
"password": ['input[name="Passwd"]'],
"password_confirm": ['input[name="PasswdAgain"]'],
},
),
"protonmail": SignupSitePreset(
key="protonmail",
label="Proton Mail",
url="https://account.proton.me/signup",
hosts=("account.proton.me", "proton.me"),
extra_selectors={
"email": ['input[id="email"]', 'input[name="email"]', 'input[type="email"]'],
"password": ['input[id="password"]', 'input[name="password"]'],
"password_confirm": ['input[id="password-confirm"]', 'input[name="passwordConfirmation"]'],
},
),
"hydraproxy": SignupSitePreset(
key="hydraproxy",
label="HydraProxy",
url="https://dashboard.hydraproxy.com/register",
hosts=("dashboard.hydraproxy.com", "hydraproxy.com"),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]', 'input[id="email"]'],
"password": ['input[name="password"]', 'input[type="password"]'],
"password_confirm": ['input[name="password_confirmation"]', 'input[name="confirmPassword"]'],
"username": ['input[name="username"]', 'input[name="name"]'],
},
),
"outlook": SignupSitePreset(
key="outlook",
label="Outlook / Microsoft",
url="https://signup.live.com/signup",
hosts=("signup.live.com", "login.live.com", "account.microsoft.com"),
extra_selectors={
"email": ['input[name="MemberName"]', 'input[type="email"]'],
"password": ['input[name="Password"]', 'input[name="PasswordInput"]'],
"first_name": ['input[name="FirstName"]'],
"last_name": ['input[name="LastName"]'],
},
),
"github": SignupSitePreset(
key="github",
label="GitHub",
url="https://github.com/signup",
hosts=("github.com",),
extra_selectors={
"email": ['input[name="user[email]"]', 'input[autocomplete="email"]'],
"password": ['input[name="user[password]"]', 'input[autocomplete="new-password"]'],
"username": ['input[name="user[login]"]'],
},
),
"reddit": SignupSitePreset(
key="reddit",
label="Reddit",
url="https://www.reddit.com/register",
hosts=("www.reddit.com", "reddit.com"),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]'],
"username": ['input[name="username"]'],
"password": ['input[name="password"]'],
},
),
"discord": SignupSitePreset(
key="discord",
label="Discord",
url="https://discord.com/register",
hosts=("discord.com",),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]'],
"username": ['input[name="username"]'],
"password": ['input[name="password"]'],
},
),
"x_twitter": SignupSitePreset(
key="x_twitter",
label="X / Twitter",
url="https://x.com/i/flow/signup",
hosts=("x.com", "twitter.com"),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]'],
"username": ['input[name="username"]'],
"password": ['input[name="password"]'],
"first_name": ['input[name="name"]'],
},
),
"telegram": SignupSitePreset(
key="telegram",
label="Telegram Web",
url="https://web.telegram.org/",
hosts=("web.telegram.org", "telegram.org"),
),
"tutanota": SignupSitePreset(
key="tutanota",
label="Tuta Mail",
url="https://app.tuta.com/signup",
hosts=("app.tuta.com", "tutanota.com", "tuta.com"),
extra_selectors={
"email": ['input[id="mailAddress"]', 'input[name="mailAddress"]'],
"password": ['input[id="password1"]', 'input[type="password"]'],
"password_confirm": ['input[id="password2"]'],
},
),
"mailcom": SignupSitePreset(
key="mailcom",
label="mail.com",
url="https://signup.mail.com/",
hosts=("signup.mail.com", "mail.com"),
extra_selectors={
"username": ['input[name="email_user"]', 'input[name="username"]'],
"password": ['input[name="password"]'],
"first_name": ['input[name="firstName"]'],
"last_name": ['input[name="lastName"]'],
},
),
}
# Public site-key list in display order. Useful for building dropdowns.
SIGNUP_PRESET_KEYS: tuple[str, ...] = (
"google", "protonmail", "outlook", "tutanota", "mailcom",
"github", "reddit", "discord", "x_twitter", "telegram",
"hydraproxy",
)
_FIRST_NAMES: tuple[str, ...] = (
"Alex", "Jordan", "Casey", "Morgan", "Taylor", "Riley", "Cameron",
"Sam", "Jamie", "Quinn", "Avery", "Drew", "Reese", "Skyler", "Logan",
"Hayden", "Parker", "Rowan", "Sage", "Emerson", "Finley", "Harper",
"Kai", "Micah", "Nico", "Phoenix", "River", "Shawn", "Sutton", "Wren",
)
_LAST_NAMES: tuple[str, ...] = (
"Reed", "Hayes", "Brooks", "Bennett", "Carter", "Foster", "Gray",
"Hughes", "Jensen", "Knight", "Lambert", "Mason", "Nash", "Owens",
"Porter", "Quincy", "Reyes", "Sutton", "Tate", "Underwood", "Vance",
"Walsh", "Yates", "Zimmerman", "Abbott", "Barker", "Cohen", "Dixon",
"Ellis", "Fitch",
)
def random_first_name() -> str:
return secrets.choice(_FIRST_NAMES)
def random_last_name() -> str:
return secrets.choice(_LAST_NAMES)
def random_full_name() -> tuple[str, str]:
return random_first_name(), random_last_name()
def random_username(first: str = "", last: str = "") -> str:
"""Generate a plausible-looking username.
If a name is provided we mix it with digits; otherwise we use two short
name fragments. Always lowercase, always 8-18 chars.
"""
f = (first or random_first_name()).lower()
l = (last or random_last_name()).lower()
seps = ("", ".", "_", "")
sep = secrets.choice(seps)
digits = "".join(secrets.choice(string.digits) for _ in range(secrets.choice([2, 3, 4])))
style = secrets.randbelow(4)
if style == 0:
base = f + sep + l + digits
elif style == 1:
base = f + digits
elif style == 2:
base = l + sep + f[0] + digits
else:
base = f[0] + sep + l + digits
base = base.strip(".-_")
return base[:18]
@dataclass
class SignupDraft:
site_key: str = "google"
custom_url: str = ""
email: str = ""
password: str = ""
username: str = ""
first_name: str = ""
last_name: str = ""
notes: str = ""
@dataclass
class AccountRecord:
id: str
site: str
url: str
email: str
password: str
username: str = ""
first_name: str = ""
last_name: str = ""
exit_ip: str = ""
exit_hop: str = ""
notes: str = ""
status: str = "created" # pending | created
created_at: str = ""
def accounts_path() -> Path:
return app_data_dir() / "signup_accounts.json"
def draft_path() -> Path:
return app_data_dir() / "signup_draft.json"
def generate_password(length: int = 18) -> str:
n = max(12, min(64, int(length)))
alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+"
while True:
pw = "".join(secrets.choice(alphabet) for _ in range(n))
if (
any(c.islower() for c in pw)
and any(c.isupper() for c in pw)
and any(c.isdigit() for c in pw)
):
return pw
def preset_for(key: str) -> SignupSitePreset | None:
return SIGNUP_PRESETS.get(key)
def resolve_signup_url(draft: SignupDraft) -> str:
if draft.site_key == "custom":
return (draft.custom_url or "").strip()
p = preset_for(draft.site_key)
return p.url if p else ""
def load_draft() -> SignupDraft:
from . import secrets_store as _ss
p = draft_path()
if not p.is_file():
return SignupDraft()
try:
raw = json.loads(p.read_text(encoding="utf-8"))
kwargs = {k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__}
if kwargs.get("password"):
kwargs["password"] = _ss.decrypt(kwargs["password"])
return SignupDraft(**kwargs)
except (OSError, json.JSONDecodeError, TypeError):
return SignupDraft()
def save_draft(draft: SignupDraft) -> None:
from . import secrets_store as _ss
d = asdict(draft)
if d.get("password"):
d["password"] = _ss.encrypt(d["password"])
draft_path().write_text(json.dumps(d, indent=2), encoding="utf-8")
def load_accounts() -> list[AccountRecord]:
from . import secrets_store as _ss
p = accounts_path()
if not p.is_file():
return []
try:
raw = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(raw, list):
return []
out: list[AccountRecord] = []
for item in raw:
if not isinstance(item, dict):
continue
fields = AccountRecord.__dataclass_fields__
kwargs = {k: item.get(k, "") for k in fields}
if not kwargs.get("id"):
kwargs["id"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
# Transparent DPAPI decrypt; plaintext passes through.
if kwargs.get("password"):
kwargs["password"] = _ss.decrypt(kwargs["password"])
out.append(AccountRecord(**kwargs))
return out
except (OSError, json.JSONDecodeError, TypeError):
return []
def save_accounts(rows: list[AccountRecord]) -> None:
from . import secrets_store as _ss
serialized = []
for r in rows:
d = asdict(r)
# Encrypt sensitive fields at rest using Windows DPAPI when available.
# Legacy plaintext entries are migrated transparently on first save.
if d.get("password"):
d["password"] = _ss.encrypt(d["password"])
serialized.append(d)
accounts_path().write_text(
json.dumps(serialized, indent=2),
encoding="utf-8",
)
def append_account(record: AccountRecord) -> None:
rows = load_accounts()
rows.insert(0, record)
save_accounts(rows)
def build_autofill_config(draft: SignupDraft) -> dict[str, Any]:
preset = preset_for(draft.site_key)
hosts = list(preset.hosts) if preset else []
selectors: dict[str, list[str]] = {}
if preset:
selectors.update(preset.extra_selectors)
fields: dict[str, str] = {}
if draft.first_name:
fields["first_name"] = draft.first_name
if draft.last_name:
fields["last_name"] = draft.last_name
if draft.username:
fields["username"] = draft.username
if draft.email:
fields["email"] = draft.email
if draft.password:
fields["password"] = draft.password
fields["password_confirm"] = draft.password
out_fields: dict[str, Any] = {}
for key, val in fields.items():
out_fields[key] = val
sel_key = f"{key}_selectors"
if key in selectors:
out_fields[sel_key] = selectors[key]
return {
"site": draft.site_key,
"hosts": hosts,
"fields": out_fields,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
def _extensions_json_entry(ext_dir_name: str) -> dict[str, Any]:
now = int(datetime.now(timezone.utc).timestamp()) * 1000
return {
"id": _EXT_ID,
"syncGUID": str(uuid.uuid4()),
"version": "1.0.0",
"type": "extension",
"location": {
"appProfile": {
"root": "app-profile",
"path": f"extensions/{ext_dir_name}",
}
},
"internalName": None,
"updateURL": None,
"webExtensionConverted": False,
"webExtensionId": _EXT_ID,
"active": True,
"userDisabled": False,
"appDisabled": False,
"installDate": now,
"scope": 1,
}
def install_signup_extension(profile_dir: Path, draft: SignupDraft) -> None:
"""Copy autofill WebExtension into the Firefox profile and write current field values."""
profile_dir.mkdir(parents=True, exist_ok=True)
ext_name = _EXT_ID
dest = profile_dir / "extensions" / ext_name
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(_ext_src_path(), dest)
cfg = build_autofill_config(draft)
(dest / "autofill_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8")
ext_json_path = profile_dir / "extensions.json"
entry = _extensions_json_entry(ext_name)
data: dict[str, Any]
if ext_json_path.is_file():
try:
data = json.loads(ext_json_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {"schemaVersion": 35, "addons": []}
else:
data = {"schemaVersion": 35, "addons": []}
addons = data.get("addons")
if not isinstance(addons, list):
addons = []
addons = [a for a in addons if isinstance(a, dict) and a.get("id") != _EXT_ID]
addons.append(entry)
data["addons"] = addons
ext_json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")

View File

@@ -0,0 +1,574 @@
"""Set / clear the Windows system-wide HTTP proxy.
Layers configured so the proxy actually applies system-wide:
1. ``HKCU\\\\Internet Settings`` — simple values WinINet reads first
2. ``Internet Settings\\Connections\\DefaultConnectionSettings`` binary blob —
overrides #1 for any process that opens the per-connection structure.
3. ``HKLM\\\\Internet Settings`` mirror (Admin only) — required on Server when
``ProxySettingsPerUser=0`` is set by Group Policy / baseline image.
4. ``netsh winhttp set proxy`` — .NET, Windows Update, services and
headless browser components that bypass WinINet.
We also actively detect and warn about:
- ``ProxySettingsPerUser=0`` → HKCU is ignored; we fix it when Admin.
- Group Policy proxy keys → those override us; we surface the path so the
user can verify or remove them.
- ``AutoConfigURL`` / ``AutoDetect`` (WPAD) → wiped on engage.
"""
from __future__ import annotations
import ctypes
import logging
import sys
import struct
import subprocess
import winreg
log = logging.getLogger(__name__)
_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
_CONN_KEY_PATH = (
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"
)
_HKLM_INET_PATH = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings"
_HKLM_CONN_PATH = (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"
)
_POLICY_PATHS = (
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"),
(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Internet Explorer\Control Panel"),
)
_SETTINGS_CHANGED = 39
_REFRESH = 37
_FLAG_DIRECT = 0x01
_FLAG_MANUAL_PROXY = 0x02
_FLAG_AUTO_CONFIG = 0x04
_FLAG_AUTO_DETECT = 0x08
def _mac_network_services() -> list[str]:
if sys.platform != "darwin":
return []
try:
r = subprocess.run(
["networksetup", "-listallnetworkservices"],
capture_output=True,
text=True,
timeout=12,
)
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_run_networksetup(args: list[str]) -> None:
try:
subprocess.run(["networksetup", *args], capture_output=True, text=True, timeout=20)
except Exception as exc:
log.debug("networksetup failed (%s): %s", args, exc)
def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None:
bypass_hosts = [
h.strip().replace("*", "")
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"])
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()))
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()))
def _mac_system_proxy_set() -> bool:
for service in _mac_network_services():
try:
r = subprocess.run(
["networksetup", "-getwebproxy", service],
capture_output=True,
text=True,
timeout=8,
)
if "Enabled: Yes" in (r.stdout or ""):
return True
except Exception:
continue
return False
def _is_admin() -> bool:
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
except Exception:
return False
def _broadcast() -> None:
"""Tell running apps (browsers, etc.) that proxy settings changed."""
try:
inet = ctypes.windll.wininet # type: ignore[attr-defined]
inet.InternetSetOptionW(0, _SETTINGS_CHANGED, 0, 0)
inet.InternetSetOptionW(0, _REFRESH, 0, 0)
except Exception:
pass
def _bump_counter(prev: bytes | None) -> int:
if not prev or len(prev) < 8:
return 1
try:
return int.from_bytes(prev[4:8], "little") + 1
except Exception:
return 1
def _build_blob(
proxy: str,
bypass: str,
auto_config_url: str = "",
counter: int = 1,
) -> bytes:
"""REG_BINARY layout for DefaultConnectionSettings (little-endian)."""
flags = _FLAG_DIRECT
if proxy:
flags |= _FLAG_MANUAL_PROXY
if auto_config_url:
flags |= _FLAG_AUTO_CONFIG
p = proxy.encode("utf-8")
b = bypass.encode("utf-8")
a = auto_config_url.encode("utf-8")
return (
struct.pack("<III", 0x46, counter, flags)
+ struct.pack("<I", len(p)) + p
+ struct.pack("<I", len(b)) + b
+ struct.pack("<I", len(a)) + a
+ b"\x00" * 32
)
def _read_blob_counter(hive: int, path: str) -> int:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
val, _ = winreg.QueryValueEx(key, "DefaultConnectionSettings")
return _bump_counter(val)
except OSError:
return 1
def _write_conn_blob(hive: int, path: str, proxy: str, bypass: str) -> bool:
counter = _read_blob_counter(hive, path)
blob = _build_blob(proxy, bypass, "", counter)
try:
key = winreg.CreateKeyEx(hive, path, 0, winreg.KEY_SET_VALUE)
with key:
winreg.SetValueEx(key, "DefaultConnectionSettings", 0, winreg.REG_BINARY, blob)
winreg.SetValueEx(key, "SavedLegacySettings", 0, winreg.REG_BINARY, blob)
return True
except OSError as e:
log.debug("Connections blob write failed (%s): %s", path, e)
return False
def _clear_conn_blob(hive: int, path: str) -> None:
counter = _read_blob_counter(hive, path)
blob = _build_blob("", "", "", counter)
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, "DefaultConnectionSettings", 0, winreg.REG_BINARY, blob)
winreg.SetValueEx(key, "SavedLegacySettings", 0, winreg.REG_BINARY, blob)
except OSError:
pass
def _wipe_autoconfig(key: winreg.HKEYType) -> None:
for name in ("AutoConfigURL", "AutoDetect"):
try:
winreg.DeleteValue(key, name)
except OSError:
pass
def _set_winhttp_proxy(host: str, port: int, bypass: str) -> None:
try:
bypass_list = (
bypass.replace(";", " ").replace("<local>", "<local>").strip()
) or "<local>"
subprocess.run(
["netsh", "winhttp", "set", "proxy",
f"{host}:{port}", f"bypass-list={bypass_list}"],
capture_output=True, text=True, timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception as e:
log.debug("netsh winhttp set proxy failed: %s", e)
def _reset_winhttp_proxy() -> None:
try:
subprocess.run(
["netsh", "winhttp", "reset", "proxy"],
capture_output=True, text=True, timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
# ── browser-policy proxy (Chrome / Edge) ─────────────────────────────────────
#
# Why this exists: when you set WebRTC policy on Chrome/Edge, the browser sees
# itself as "managed". Some managed-Chrome builds then *ignore* the system
# proxy unless an explicit ``ProxyServer`` policy is also set. They also keep
# trying HTTP/3 over QUIC (UDP/443), which bypasses HTTP proxies entirely —
# under the kill-switch those UDP packets get dropped and pages hang forever.
#
# Setting these policy values pins the browser to our chain (HTTP proxy) and
# forces it off QUIC. Cleared cleanly on disengage.
_BROWSER_POLICY_PATHS = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
r"SOFTWARE\Policies\Chromium",
)
def _set_browser_proxy_policy(host: str, port: int, bypass: str) -> int:
"""Pin Chromium-based browsers (Chrome, Edge, Chromium) to our local proxy
via Group Policy registry, and disable QUIC. Returns count of keys updated.
Requires Admin to write under HKLM\\SOFTWARE\\Policies. Silently skips when
not elevated — the user gets a warning in the service log instead.
"""
if not _is_admin():
return 0
proxy_value = f"http={host}:{port};https={host}:{port}"
bypass_value = bypass.replace(";", ",")
written = 0
for path in _BROWSER_POLICY_PATHS:
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyMode", 0, winreg.REG_SZ, "fixed_servers")
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy_value)
winreg.SetValueEx(key, "ProxyBypassList", 0, winreg.REG_SZ, bypass_value)
winreg.SetValueEx(key, "QuicAllowed", 0, winreg.REG_DWORD, 0)
written += 1
except OSError:
continue
return written
def _clear_browser_proxy_policy() -> None:
if not _is_admin():
return
for path in _BROWSER_POLICY_PATHS:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
for name in ("ProxyMode", "ProxyServer", "ProxyBypassList", "QuicAllowed"):
try:
winreg.DeleteValue(key, name)
except OSError:
pass
except OSError:
continue
def _read_proxy_settings_per_user() -> int | None:
"""``HKLM\\\\Internet Settings\\ProxySettingsPerUser``.
When 0, Windows ignores HKCU proxy values and reads only HKLM. Set on many
Server images and AD policy baselines — the #1 cause of "set the proxy but
the browser still goes direct" on Server.
"""
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, "ProxySettingsPerUser")
return int(val)
except OSError:
return None
def _fix_proxy_settings_per_user() -> bool:
"""Force ``ProxySettingsPerUser=1`` (per-user proxy honored). Admin only."""
if not _is_admin():
return False
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxySettingsPerUser", 0, winreg.REG_DWORD, 1)
log.info("HKLM\\\\ProxySettingsPerUser → 1 (was %s)", _read_proxy_settings_per_user())
return True
except OSError as e:
log.warning("Could not fix ProxySettingsPerUser: %s", e)
return False
def _set_hklm_root(proxy: str, bypass: str) -> bool:
"""Mirror the per-user keys at HKLM so machine-scope WinINet reads honor us."""
if not _is_admin():
return False
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
_wipe_autoconfig(key)
return True
except OSError as e:
log.warning("HKLM root proxy write failed: %s", e)
return False
def _clear_hklm_root() -> None:
if not _is_admin():
return
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
_wipe_autoconfig(key)
except OSError:
pass
def detect_policy_overrides() -> list[str]:
if sys.platform != "win32":
return []
"""Return human-readable paths of Group Policy proxy locks that beat us.
Anything we find here will silently override our manual proxy. We do *not*
delete policy keys (admins set those deliberately) — we just surface them.
"""
hits: list[str] = []
proxy_value_names = ("ProxyServer", "ProxyEnable", "AutoConfigURL", "ProxyOverride")
for hive, path in _POLICY_PATHS:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
for n in proxy_value_names:
try:
winreg.QueryValueEx(key, n)
hive_name = "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU"
hits.append(f"{hive_name}\\{path}\\{n}")
except OSError:
continue
except OSError:
continue
return hits
def _read_str(hive: int, path: str, name: str) -> str:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
val, _ = winreg.QueryValueEx(key, name)
return str(val)
except OSError:
return ""
def _read_dword(hive: int, path: str, name: str) -> int | None:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
val, _ = winreg.QueryValueEx(key, name)
return int(val)
except OSError:
return None
def _read_winhttp_proxy() -> str:
try:
r = subprocess.run(
["netsh", "winhttp", "show", "proxy"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
for ln in (r.stdout or "").splitlines():
if "proxy" in ln.lower() and ":" in ln:
return ln.strip()
return (r.stdout or "").strip().splitlines()[-1] if r.stdout else ""
except Exception:
return ""
def diagnose_system_proxy() -> list[str]:
if sys.platform == "darwin":
out = []
for service in _mac_network_services()[:8]:
try:
r = subprocess.run(
["networksetup", "-getwebproxy", service],
capture_output=True,
text=True,
timeout=8,
)
first = " ".join((r.stdout or "").splitlines()[:3])
except Exception as exc:
first = str(exc)
out.append(f"macOS {service}: {first}")
return out or ["macOS network services not detected"]
"""Return a list of human-readable lines covering every place proxy can
be configured. Logged on each engage so "chain green but browser leaks"
is debuggable without RegEdit gymnastics.
"""
lines: list[str] = []
per_user = _read_proxy_settings_per_user()
per_user_msg = (
"1 (HKCU honored)" if per_user == 1
else "0 (HKCU IGNORED — Server lock!)" if per_user == 0
else "(unset → defaults to HKCU)"
)
lines.append(f"ProxySettingsPerUser = {per_user_msg}")
en_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyEnable")
ps_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyServer")
lines.append(f"HKCU Enable={en_u} Server='{ps_u}'")
en_m = _read_dword(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyEnable")
ps_m = _read_str(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyServer")
lines.append(f"HKLM Enable={en_m} Server='{ps_m}'")
ac_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoConfigURL")
ad_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoDetect")
if ac_u or ad_u:
lines.append(f"WPAD/PAC: AutoConfigURL='{ac_u}' AutoDetect={ad_u}")
winhttp = _read_winhttp_proxy()
if winhttp:
lines.append(f"WinHTTP: {winhttp}")
pol = detect_policy_overrides()
if pol:
lines.append(f"Policy overrides (these BEAT us): {len(pol)} entries")
for p in pol[:5]:
lines.append(f" ! {p}")
if len(pol) > 5:
lines.append(f" ! …and {len(pol) - 5} more")
else:
lines.append("Policy overrides: none")
return lines
def set_system_proxy(
host: str,
port: int,
bypass: str = "localhost;127.*;10.*;192.168.*;<local>",
) -> None:
if sys.platform == "darwin":
_mac_set_system_proxy(host, port, bypass)
return
proxy = f"{host}:{port}"
# HKCU root
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
_wipe_autoconfig(key)
except OSError:
log.exception("Failed to set system proxy (HKCU root)")
return
_write_conn_blob(winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH, proxy, bypass)
# If ProxySettingsPerUser=0 (Server / GPO baseline), HKCU is ignored.
# When Admin, fix the flag AND mirror to HKLM so the proxy actually applies.
per_user = _read_proxy_settings_per_user()
if per_user == 0:
log.warning(
"ProxySettingsPerUser=0 detected — Windows is ignoring per-user "
"proxy. Attempting Admin fix (HKLM mirror)."
)
if _is_admin():
_fix_proxy_settings_per_user()
_set_hklm_root(proxy, bypass)
_write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass)
else:
log.warning(
"Not running as Admin — cannot fix ProxySettingsPerUser. "
"Re-launch elevated for proxy to apply on this Server image."
)
# On Admin runs always mirror HKLM root too. Harmless when PerUser=1 and
# correct when PerUser=0 or absent.
if _is_admin():
_set_hklm_root(proxy, bypass)
_write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass)
_set_winhttp_proxy(host, port, bypass)
n_browser = _set_browser_proxy_policy(host, port, bypass)
_broadcast()
log.info(
"System proxy set %s (bypass=%s) — HKCU + HKLM(adm) + Connections + WinHTTP + %d browser policy",
proxy, bypass, n_browser,
)
def clear_system_proxy() -> None:
if sys.platform == "darwin":
_mac_clear_system_proxy()
return
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
_wipe_autoconfig(key)
except OSError:
log.exception("Failed to clear system proxy")
_clear_conn_blob(winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH)
if _is_admin():
_clear_hklm_root()
_clear_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH)
_reset_winhttp_proxy()
_clear_browser_proxy_policy()
_broadcast()
log.info("System proxy cleared — HKCU + HKLM(adm) + Connections + WinHTTP + browser policy")
def is_system_proxy_set() -> bool:
if sys.platform == "darwin":
return _mac_system_proxy_set()
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, "ProxyEnable")
return val == 1
except OSError:
return False

View File

@@ -0,0 +1,292 @@
"""Reversibly disable Windows telemetry / data collection.
Targets, in order of leak severity:
• DiagTrack service — primary telemetry pipeline ("Connected User
Experiences and Telemetry"). Phones home every
few minutes.
• dmwappushservice — WAP Push routing (DSMS), historically tied to
DiagTrack. Disabled when present.
• AllowTelemetry (HKLM) — DataCollection policy. 0 = lowest tier.
• Activity History — EnableActivityFeed / Upload / PublishUserActivities.
• Advertising ID — HKCU\\\\AdvertisingInfo\\Enabled.
• Cortana Bing search — HKCU\\\\Search\\BingSearchEnabled.
• Scheduled tasks — Compatibility Appraiser + CEIP Consolidator
+ UsbCeip.
Every change is snapshotted before mutation; ``restore_telemetry`` rolls back.
"""
from __future__ import annotations
import json
import logging
import subprocess
import winreg
from dataclasses import dataclass, field
from .firewall import is_admin
log = logging.getLogger(__name__)
_SERVICES = ("DiagTrack", "dmwappushservice")
_SCHED_TASKS = (
r"\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser",
r"\Microsoft\Windows\Customer Experience Improvement Program\Consolidator",
r"\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip",
)
# (hive, path, value_name, dword_when_killed)
_REG_KILLS = (
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\DataCollection",
"AllowTelemetry", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\System",
"EnableActivityFeed", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\System",
"PublishUserActivities", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\System",
"UploadUserActivities", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\CloudContent",
"DisableWindowsConsumerFeatures", 1),
(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo",
"Enabled", 0),
(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Search",
"BingSearchEnabled", 0),
(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Search",
"CortanaConsent", 0),
)
@dataclass
class TelemetrySnapshot:
service_state: dict[str, str] = field(default_factory=dict) # name -> 'auto'|'manual'|'disabled'|'absent'
sched_tasks_state: dict[str, bool] = field(default_factory=dict) # task -> was_enabled
reg_values: list[dict] = field(default_factory=list)
def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args, capture_output=True, text=True, timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
# ── service helpers ──────────────────────────────────────────────────────────
def _service_start_type(name: str) -> str:
code, out, _ = _run(["sc", "qc", name])
if code != 0:
return "absent"
for ln in out.splitlines():
s = ln.strip().lower()
if "start_type" in s:
if "2" in s or "auto" in s:
return "auto"
if "3" in s or "demand" in s or "manual" in s:
return "manual"
if "4" in s or "disabled" in s:
return "disabled"
return "absent"
def _set_service_disabled(name: str) -> bool:
_run(["sc", "stop", name], timeout=15)
code, _, _ = _run(["sc", "config", name, "start=", "disabled"])
return code == 0
def _set_service_start(name: str, want: str) -> bool:
start_value = {"auto": "auto", "manual": "demand", "disabled": "disabled"}.get(want)
if not start_value:
return False
_run(["sc", "stop", name], timeout=10)
code, _, _ = _run(["sc", "config", name, "start=", start_value])
if want == "auto":
_run(["sc", "start", name], timeout=15)
return code == 0
# ── scheduled-task helpers ──────────────────────────────────────────────────
def _task_is_enabled(name: str) -> bool | None:
code, out, _ = _run(["schtasks", "/Query", "/TN", name])
if code != 0:
return None
for ln in out.splitlines():
s = ln.strip().lower()
if s.startswith("status:") or "ready" in s or "disabled" in s:
if "disabled" in s:
return False
if "ready" in s or "running" in s:
return True
return True # assume ready when query succeeded
def _set_task_state(name: str, enable: bool) -> bool:
code, _, _ = _run([
"schtasks", "/Change", "/TN", name,
"/Enable" if enable else "/Disable",
])
return code == 0
# ── registry helpers ────────────────────────────────────────────────────────
def _read_reg_dword(hive: int, path: str, name: str) -> tuple[bool, int | None]:
"""Returns (present, value)."""
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
v, _ = winreg.QueryValueEx(key, name)
return True, int(v)
except OSError:
return False, None
def _write_reg_dword(hive: int, path: str, name: str, value: int) -> bool:
try:
with winreg.CreateKeyEx(hive, path, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value)
return True
except OSError:
return False
def _delete_reg_value(hive: int, path: str, name: str) -> None:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_SET_VALUE) as key:
winreg.DeleteValue(key, name)
except OSError:
pass
# ── public API ──────────────────────────────────────────────────────────────
def telemetry_status() -> dict[str, str]:
"""Read-only snapshot for the audit panel."""
out: dict[str, str] = {}
for svc in _SERVICES:
out[f"svc.{svc}"] = _service_start_type(svc)
pres, val = _read_reg_dword(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\DataCollection",
"AllowTelemetry",
)
out["AllowTelemetry"] = "0 (killed)" if pres and val == 0 else (
f"{val}" if pres else "unset"
)
pres, val = _read_reg_dword(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo",
"Enabled",
)
out["AdvertisingID"] = "off" if pres and val == 0 else (
"on" if pres and val == 1 else "unset"
)
return out
def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]:
logs: list[str] = []
if not is_admin():
return None, ["Telemetry kill skipped — needs Administrator."]
snap = TelemetrySnapshot()
for svc in _SERVICES:
st = _service_start_type(svc)
snap.service_state[svc] = st
if st == "absent":
continue
if _set_service_disabled(svc):
logs.append(f"Service '{svc}' stopped + disabled (was {st}).")
else:
logs.append(f"Service '{svc}' could not be disabled.")
for task in _SCHED_TASKS:
was = _task_is_enabled(task)
if was is None:
continue
snap.sched_tasks_state[task] = was
if was:
if _set_task_state(task, enable=False):
logs.append(f"Scheduled task disabled: {task}")
for hive, path, name, kill_val in _REG_KILLS:
present, prev = _read_reg_dword(hive, path, name)
snap.reg_values.append({
"hive": "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU",
"path": path,
"name": name,
"present": present,
"prev": prev,
})
if _write_reg_dword(hive, path, name, kill_val):
logs.append(f"reg: {('HKLM' if hive == winreg.HKEY_LOCAL_MACHINE else 'HKCU')}\\{path}\\{name}{kill_val}")
else:
logs.append(f"reg write failed: {path}\\{name}")
return snap, logs
def restore_telemetry(snap: TelemetrySnapshot | None) -> list[str]:
if snap is None or not is_admin():
return []
logs: list[str] = []
for svc, prev in snap.service_state.items():
if prev == "absent":
continue
if _set_service_start(svc, prev):
logs.append(f"Service '{svc}' restored → {prev}.")
for task, was in snap.sched_tasks_state.items():
if was:
_set_task_state(task, enable=True)
logs.append(f"Scheduled task re-enabled: {task}")
for entry in snap.reg_values:
hive = winreg.HKEY_LOCAL_MACHINE if entry["hive"] == "HKLM" else winreg.HKEY_CURRENT_USER
path = entry["path"]
name = entry["name"]
if entry["present"]:
_write_reg_dword(hive, path, name, int(entry["prev"]))
logs.append(f"reg restore: {entry['hive']}\\{path}\\{name}{entry['prev']}")
else:
_delete_reg_value(hive, path, name)
logs.append(f"reg removed (was absent): {entry['hive']}\\{path}\\{name}")
return logs
def snapshot_to_json(snap: TelemetrySnapshot | None) -> str:
if snap is None:
return ""
return json.dumps({
"services": snap.service_state,
"tasks": snap.sched_tasks_state,
"reg": snap.reg_values,
})
def snapshot_from_json(raw: str) -> TelemetrySnapshot | None:
if not raw:
return None
try:
d = json.loads(raw)
return TelemetrySnapshot(
service_state=dict(d.get("services") or {}),
sched_tasks_state={k: bool(v) for k, v in (d.get("tasks") or {}).items()},
reg_values=list(d.get("reg") or []),
)
except Exception:
return None

160
proxy_chain_manager/tray.py Normal file
View File

@@ -0,0 +1,160 @@
"""System tray: LED-style proxy on/off + hover tooltip with exit IP."""
from __future__ import annotations
import logging
import sys
import threading
from typing import Any, Callable
log = logging.getLogger(__name__)
try:
import pystray
except Exception as exc: # noqa: BLE001
pystray = None # type: ignore[assignment]
log.warning("System tray unavailable: %s", exc)
from PIL import Image, ImageDraw, ImageFilter
_SIZE = 64
def _hex_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _led_icon(led: str, glow: str, rim: str = "#2d3748") -> Image.Image:
"""Traffic-light style icon: dark housing + glowing LED."""
base = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(base)
cx, cy = _SIZE // 2, _SIZE // 2
# Outer housing (rounded rect feel via ellipse)
draw.ellipse([4, 4, _SIZE - 4, _SIZE - 4], fill="#0d0d1a", outline=rim, width=2)
draw.ellipse([10, 10, _SIZE - 10, _SIZE - 10], fill="#12122a", outline="#1a1a3e", width=1)
glow_layer = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0))
g = ImageDraw.Draw(glow_layer)
lr, lg, lb = _hex_rgb(glow)
for radius, alpha in ((22, 35), (16, 70), (11, 120)):
g.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(lr, lg, lb, alpha),
)
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=2))
base = Image.alpha_composite(base, glow_layer)
draw = ImageDraw.Draw(base)
dr, dg, db = _hex_rgb(led)
draw.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], fill=(dr, dg, db, 255))
draw.ellipse([cx - 5, cy - 6, cx + 2, cy - 1], fill=(255, 255, 255, 90))
return base
ICONS = {
"green": _led_icon("#00e676", "#00e676"),
"red": _led_icon("#ff1744", "#ff1744"),
"yellow": _led_icon("#ffab00", "#ffab00"),
"gray": _led_icon("#4a5568", "#3d4a5c", rim="#374151"),
}
_STATE_LABEL = {
"green": "PROXY ON",
"red": "PROXY OFF / ERROR",
"yellow": "CONNECTING…",
"gray": "STOPPED",
}
def _tooltip(state: str, exit_ip: str | None) -> str:
label = _STATE_LABEL.get(state, "Proxy God")
if exit_ip:
return f"Proxy God — {label}\nExit IP: {exit_ip}"
if state == "green":
return f"Proxy God — {label}\nExit IP: (checking…)"
return f"Proxy God — {label}\nExit IP: —"
class TrayIcon:
def __init__(
self,
on_show: Callable[[], None],
on_quit: Callable[[], None],
on_rotate: Callable[[], None],
) -> None:
self._on_show = on_show
self._on_quit = on_quit
self._on_rotate = on_rotate
self._icon: pystray.Icon | None = None
self._thread: threading.Thread | None = None
self._state = "gray"
self._exit_ip: str | None = None
self._lock = threading.Lock()
def start(self) -> None:
if sys.platform == "darwin":
log.info("Tray icon disabled on macOS; use the main window controls.")
return
if pystray is None:
return
if self._thread and self._thread.is_alive():
return
menu = pystray.Menu(
pystray.MenuItem("Show Proxy God", self._show, default=True),
pystray.MenuItem("Rotate chain now", self._rotate),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", self._quit),
)
with self._lock:
tip = _tooltip(self._state, self._exit_ip)
self._icon = pystray.Icon(
"ProxyGod",
icon=ICONS[self._state],
title=tip,
menu=menu,
)
self._thread = threading.Thread(target=self._icon.run, daemon=True)
self._thread.start()
def stop(self) -> None:
if sys.platform == "darwin":
return
if pystray is None:
return
if self._icon:
try:
self._icon.stop()
except Exception as exc:
log.warning("Tray icon stop failed: %s", exc)
def set_state(self, state: str, exit_ip: str | None = None) -> None:
"""state: green (on), red (error), yellow (connecting), gray (stopped).
Pass exit_ip to refresh the hover tooltip. Use exit_ip=None to keep the last IP.
"""
if state not in ICONS:
state = "gray"
with self._lock:
self._state = state
if exit_ip is not None:
self._exit_ip = exit_ip.strip() if exit_ip else None
if self._icon:
self._icon.icon = ICONS[state]
self._icon.title = _tooltip(state, self._exit_ip)
def set_exit_ip(self, exit_ip: str | None) -> None:
"""Update tooltip only (e.g. after health check, same LED color)."""
with self._lock:
self._exit_ip = exit_ip.strip() if exit_ip else None
if self._icon:
self._icon.title = _tooltip(self._state, self._exit_ip)
def _show(self, icon: Any = None, item: Any = None) -> None:
self._on_show()
def _rotate(self, icon: Any = None, item: Any = None) -> None:
self._on_rotate()
def _quit(self, icon: Any = None, item: Any = None) -> None:
self._on_quit()

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Callable
import httpx
log = logging.getLogger(__name__)
# Secondary fallback endpoints for IP resolution.
# HTTP endpoints come first: many free proxies and older Windows Server
# environments fail HTTPS CONNECT through chained hops. Plain HTTP IP-check
# succeeds even when only forward-proxying (no CONNECT) works, so the chain's
# "last leg" exit-IP verification doesn't fail just because TLS can't tunnel.
_IP_FALLBACKS = [
"http://api.ipify.org/?format=json",
"http://checkip.amazonaws.com/",
"http://ip-api.com/json/",
"http://ifconfig.me/ip",
"https://api.ipify.org?format=json",
"https://httpbin.org/ip",
"https://ifconfig.me/ip",
]
def _parse_ip(text: str) -> str | None:
"""Parse an IP string from JSON or plain-text response body."""
body = text.strip()
if not body:
return None
if body.startswith("{"):
try:
d = json.loads(body)
ip = d.get("ip") or d.get("origin") or d.get("query")
return str(ip).split(",")[0].strip() if ip else None
except Exception:
return None
# Plain-text: must look like an IP (short, no HTML)
if len(body) < 50 and "<" not in body:
candidate = body.split()[0].strip()
if candidate.count(".") >= 3 or ":" in candidate:
return candidate
return None
async def validate_proxies(
proxy_urls: list[str],
check_url: str,
concurrency: int,
timeout_seconds: float,
on_progress: Callable[[int, int], None] | None = None,
target: int = 0,
) -> list[str]:
"""Validate proxies concurrently.
Args:
target: Stop as soon as this many valid proxies are found (0 = test all).
"""
if not proxy_urls:
return []
sem = asyncio.Semaphore(max(1, concurrency))
ok: list[str] = []
lock = asyncio.Lock()
total = len(proxy_urls)
done = 0
c = max(1, concurrency)
waves = (total + c - 1) // c
cap = min(600.0, float(waves) * float(timeout_seconds) + 45.0)
cap = max(90.0, cap)
log.info(
"validate_proxies: n=%d concurrency=%d per_timeout=%.1fs wall_cap~=%.0fs target=%s check=%s",
total,
c,
timeout_seconds,
cap,
target if target else "all",
(check_url[:70] + "") if len(check_url) > 70 else check_url,
)
last_prog_t = 0.0
prog_step = max(1, total // 120)
enough = False # set True once target reached; tasks skip HTTP call and drain fast
async def one(u: str) -> None:
nonlocal done, last_prog_t, enough
# Skip cost of the HTTP check once we already have enough
if enough:
async with lock:
done += 1
if on_progress and done == total:
on_progress(done, total)
return
async with sem:
if enough: # re-check after potentially waiting on semaphore
good = False
else:
good = await _check_one(u, check_url, timeout_seconds)
async with lock:
done += 1
if good:
ok.append(u)
if target and len(ok) >= target:
enough = True
log.info("validate_proxies: target %d reached after %d tested", target, done)
if on_progress:
now = time.monotonic()
if (
done == total
or done == 1
or enough
or done % prog_step == 0
or (now - last_prog_t) >= 0.1
):
last_prog_t = now
on_progress(done, total)
async def _run_all() -> None:
await asyncio.gather(*(one(u) for u in proxy_urls))
try:
await asyncio.wait_for(_run_all(), timeout=cap)
except asyncio.TimeoutError:
log.warning(
"validate_proxies wall-clock cap %.0fs exceeded (%d URLs) — returning partial results",
cap,
total,
)
log.info("validate_proxies: finished ok=%d / %d tested", len(ok), done)
return ok
async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) -> bool:
"""Check a single proxy by fetching check_url through it. True = live and returns 200."""
t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
try:
async with httpx.AsyncClient(
proxy=proxy_url,
timeout=t,
verify=False,
follow_redirects=True,
) as c:
r = await c.get(check_url)
return r.status_code == 200 and len(r.content) > 0
except Exception:
return False
async def check_chain_exit_ip(
listen_proxy: str,
check_url: str,
timeout_seconds: float,
chain_hops: int = 3,
) -> str | None:
"""Query the IP-check URL through the local chain proxy.
Bounded total time — never stacks one slow request per fallback URL forever.
Per-request timeout scales mildly with chain length so 5+ hop chains on
slower hardware (older Windows Server, low-spec VPS) don't time out on
cumulative TLS/CONNECT handshakes.
"""
hops = max(1, int(chain_hops))
scale = 1.0 + max(0, hops - 3) * 0.35
per = max(5.0, min(30.0, float(timeout_seconds) * scale))
budget = max(20.0, min(90.0, float(timeout_seconds) * 2 * scale + 5.0))
# Try more endpoints with HTTP first; if any hop blocks CONNECT, HTTPS
# IP-check would fail and the whole chain looks "dead" at the last step.
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:4]
log.debug(
"check_chain_exit_ip: hops=%d budget=%.1fs per_req=%.1fs fallback_count=%d",
hops,
budget,
per,
len(urls),
)
async def _run() -> str | None:
t = httpx.Timeout(per, connect=min(8.0, per))
try:
async with httpx.AsyncClient(
proxy=listen_proxy,
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception:
continue
except Exception:
pass
return None
try:
return await asyncio.wait_for(_run(), timeout=budget)
except asyncio.TimeoutError:
log.debug("check_chain_exit_ip timed out after %.1fs", budget)
return None
async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str | None:
"""Get our own exit IP without the proxy chain (so we can compare)."""
per = max(5.0, min(15.0, float(timeout_seconds)))
budget = max(12.0, min(45.0, float(timeout_seconds) * 2))
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:2]
async def _run() -> str | None:
t = httpx.Timeout(per)
try:
async with httpx.AsyncClient(
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception:
continue
except Exception:
pass
return None
try:
return await asyncio.wait_for(_run(), timeout=budget)
except asyncio.TimeoutError:
log.debug("get_direct_ip timed out after %.1fs", budget)
return None
async def check_https_tunnel(
listen_proxy: str,
timeout_seconds: float = 12.0,
) -> tuple[bool, str]:
"""Return (ok, message). True means the chain successfully tunneled HTTPS.
Many free proxies pass plain HTTP but refuse HTTP ``CONNECT`` (the method
required to tunnel TLS). The chain can look healthy on an IP-check via
plain HTTP yet break every HTTPS page a browser tries to load. This probe
catches that case at start time so the user gets a clear warning instead
of a mystery "browser broken".
"""
per = max(5.0, min(20.0, float(timeout_seconds)))
t = httpx.Timeout(per, connect=min(8.0, per))
# Use a tiny, fast, very-common HTTPS endpoint.
targets = [
"https://www.google.com/generate_204",
"https://www.cloudflare.com/cdn-cgi/trace",
"https://duckduckgo.com/",
]
last_err = ""
try:
async with httpx.AsyncClient(
proxy=listen_proxy, timeout=t, verify=False, follow_redirects=True,
) as c:
for url in targets:
try:
r = await c.get(url)
if 200 <= r.status_code < 400:
return True, f"HTTPS OK via {url} ({r.status_code})"
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
return False, last_err or "all HTTPS targets failed"

View File

@@ -0,0 +1,233 @@
"""Detect whether a VPN tunnel is active on Windows (any provider)."""
from __future__ import annotations
import glob
import logging
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger(__name__)
# Adapter description / name substrings (case-insensitive)
_VPN_ADAPTER_HINTS = (
"nordlynx", "nordvpn", "openvpn", "wireguard", "wintun", "tap-windows",
"tailscale", "zerotier", "cisco anyconnect", "fortinet", "pulse secure",
"globalprotect", "softether", "proton", "mullvad", "expressvpn",
"surfshark", "private internet", "pia ", "windscribe", "hotspot shield",
)
_NON_VPN_HINTS = (
"wan miniport",
"microsoft kernel debug",
"isatap",
"teredo",
"loopback",
"pseudo-interface",
)
# Executables to whitelist in kill-switch when present
_VPN_EXE_GLOBS: list[str] = [
r"C:\Program Files\NordVPN\*.exe",
r"C:\Program Files\NordUpdater\*.exe",
r"C:\Program Files\NordVPN\NordSec ThreatProtection\*.exe",
r"C:\Program Files\OpenVPN\bin\*.exe",
r"C:\Program Files\OpenVPN Connect\*.exe",
r"C:\Program Files\WireGuard\*.exe",
r"C:\Program Files\Proton\VPN\*.exe",
r"C:\Program Files\Mullvad VPN\*.exe",
r"C:\Program Files\ExpressVPN\*.exe",
r"C:\Program Files\Surfshark\*.exe",
r"C:\Program Files\Private Internet Access\*.exe",
r"C:\Program Files\Tailscale\*.exe",
r"C:\Program Files\ZeroTier\One\*.exe",
]
_PROVIDER_FROM_ADAPTER: list[tuple[str, str]] = [
("nordlynx", "NordVPN"),
("nordvpn", "NordVPN"),
("wireguard", "WireGuard"),
("wintun", "WireGuard"),
("openvpn", "OpenVPN"),
("proton", "Proton VPN"),
("mullvad", "Mullvad"),
("expressvpn", "ExpressVPN"),
("surfshark", "Surfshark"),
("tailscale", "Tailscale"),
("zerotier", "ZeroTier"),
("tap-windows", "OpenVPN/TAP"),
("globalprotect", "GlobalProtect"),
("fortinet", "FortiClient"),
("cisco", "Cisco VPN"),
]
@dataclass
class VpnStatus:
active: bool = False
label: str = "Direct (no VPN)"
adapter: str = ""
adapters: list[str] = field(default_factory=list)
def short_label(self) -> str:
if not self.active:
return "Direct"
return self.label
def _run_ps(script: str, timeout: float = 12.0) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip()
except Exception as e:
log.debug("vpn_detect powershell failed: %s", e)
return ""
def _match_provider(name: str) -> str:
low = name.lower()
for hint, label in _PROVIDER_FROM_ADAPTER:
if hint in low:
return label
if "vpn" in low or "tunnel" in low:
return "VPN"
return "VPN"
def detect_vpn() -> VpnStatus:
"""Inspect up network adapters for VPN/tunnel interfaces."""
if sys.platform == "darwin":
try:
r = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=8)
names: list[str] = []
for line in (r.stdout or "").splitlines():
if line and not line.startswith("\t") and ":" in line:
name = line.split(":", 1)[0].strip()
if name:
names.append(name)
hits = [n for n in names if n.startswith(("utun", "tun", "tap", "wg"))]
if not hits:
return VpnStatus(active=False, label="Direct (no VPN)", adapters=names)
primary = hits[0]
return VpnStatus(active=True, label=_match_provider(primary), adapter=primary, adapters=names)
except Exception:
return VpnStatus()
out = _run_ps(
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
"ForEach-Object { \"$($_.Name)|$($_.InterfaceDescription)\" }"
)
if not out:
# Fallback for PowerShell 2.0 / older Windows Server: parse "wmic nic"
# then "netsh interface show interface". Both are locale-tolerant.
try:
r = subprocess.run(
["wmic", "nic", "where", "NetEnabled=true",
"get", "NetConnectionID,Name", "/format:list"],
capture_output=True,
text=True,
timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
blob = (r.stdout or "")
names: list[str] = []
cur_id = ""
cur_name = ""
for ln in blob.splitlines():
ln = ln.strip()
if not ln:
if cur_id:
names.append(f"{cur_id}|{cur_name}")
cur_id, cur_name = "", ""
continue
if ln.startswith("NetConnectionID="):
cur_id = ln.split("=", 1)[1].strip()
elif ln.startswith("Name="):
cur_name = ln.split("=", 1)[1].strip()
if cur_id:
names.append(f"{cur_id}|{cur_name}")
out = "\n".join(names)
except Exception:
out = ""
if not out:
# Last-ditch: netsh. Locale-tolerant — match the connected/enabled state
# by extracting the interface name from the rightmost column. Older
# localized Server SKUs (es, de, fr, etc.) don't print literal "Enabled".
try:
r = subprocess.run(
["netsh", "interface", "show", "interface"],
capture_output=True,
text=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (r.stdout or "")
names = []
for ln in raw.splitlines():
s = ln.rstrip()
if not s or s.startswith("-") or ":" in s.split(" ")[0]:
continue
parts = re.split(r"\s{2,}", s.strip())
if len(parts) >= 4:
nm = parts[-1].strip()
if nm and nm.lower() not in ("interface name", "nombre de interfaz"):
names.append(f"{nm}|")
out = "\n".join(names)
except Exception:
return VpnStatus()
adapters: list[str] = []
adapter_blob: list[str] = []
for line in out.splitlines():
raw = line.strip()
if not raw:
continue
if "|" in raw:
name, desc = raw.split("|", 1)
else:
name, desc = raw, ""
name = name.strip()
desc = desc.strip()
if name:
adapters.append(name)
adapter_blob.append((name + " " + desc).strip())
hits: list[str] = []
for i, name in enumerate(adapters):
low = adapter_blob[i].lower()
if any(h in low for h in _NON_VPN_HINTS):
continue
if any(h in low for h in _VPN_ADAPTER_HINTS):
hits.append(name)
if not hits:
return VpnStatus(active=False, label="Direct (no VPN)", adapters=adapters)
primary = hits[0]
return VpnStatus(
active=True,
label=_match_provider(primary),
adapter=primary,
adapters=adapters,
)
def expand_vpn_executables() -> list[str]:
"""Paths to VPN client binaries for firewall allow rules."""
seen: set[str] = set()
out: list[str] = []
for pattern in _VPN_EXE_GLOBS:
for p in glob.glob(pattern):
rp = str(Path(p).resolve())
if rp not in seen:
seen.add(rp)
out.append(rp)
return out

View File

@@ -0,0 +1,231 @@
"""WebRTC leak detection and hardening checks.
WebRTC leaks expose your real IP through STUN/ICE even when a proxy is set.
This module checks every controllable surface:
1. OS-level Chrome / Edge Group Policy (prevents non-proxied UDP)
2. Firefox managed profile prefs (user.js must disable PeerConnection)
3. STUN UDP reachability (if STUN is reachable, WebRTC CAN leak)
4. Firefox process prefs (confirm our user.js was accepted)
"""
from __future__ import annotations
import logging
import socket
import subprocess
import winreg
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger(__name__)
# STUN servers commonly used by WebRTC
_STUN_HOSTS: list[tuple[str, int]] = [
("stun.l.google.com", 19302),
("stun1.l.google.com", 19302),
("stun.cloudflare.com", 3478),
("stun.stunprotocol.org", 3478),
]
# Firefox user.js prefs that block WebRTC
_REQUIRED_WEBRTC_PREFS: dict[str, str] = {
"media.peerconnection.enabled": "false",
"media.peerconnection.ice.default_address_only": "true",
"media.peerconnection.ice.no_host": "true",
}
# Chrome / Edge HKLM policy keys
_CHROMIUM_POLICY_PATHS = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
r"SOFTWARE\Policies\Chromium",
)
_WEBRTC_POLICY_VALUE = "DefaultWebRtcIpHandlingPolicy"
_WEBRTC_BLOCK_VALUE = 3 # disable_non_proxied_udp (was 2 = public+private only — wrong)
_WEBRTC_POLICY_STR_KEY = "WebRtcIPHandling"
_WEBRTC_BLOCK_STR_VALUE = "disable_non_proxied_udp"
@dataclass
class WebRtcCheckResult:
"""Aggregated WebRTC leak surface scan."""
# Policy / config checks
chrome_edge_policy_set: bool = False
chrome_edge_policy_detail: str = ""
firefox_prefs_ok: bool = False
firefox_prefs_detail: str = ""
# Network reachability
stun_reachable: bool = False
stun_detail: str = ""
# Convenience flags
any_leak_risk: bool = True
issues: list[str] = field(default_factory=list)
checks: list[tuple[str, bool, str]] = field(default_factory=list) # (name, ok, detail)
def add(self, name: str, ok: bool, detail: str) -> None:
self.checks.append((name, ok, detail))
if not ok:
self.issues.append(f"{name}: {detail}")
# ─────────────────────────────────────────────────────────────────────────────
# 1. Chrome / Edge Group Policy
# ─────────────────────────────────────────────────────────────────────────────
def check_chromium_webrtc_policy() -> tuple[bool, str]:
"""Return (policy_set, detail_string).
Accepts either the legacy DWORD DefaultWebRtcIpHandlingPolicy==3
OR the modern REG_SZ WebRtcIPHandling=="disable_non_proxied_udp".
"""
found: list[str] = []
missing: list[str] = []
for path in _CHROMIUM_POLICY_PATHS:
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_QUERY_VALUE) as k:
name = path.split("\\")[-1]
dword_ok = False
str_ok = False
# Check legacy DWORD
try:
val = int(winreg.QueryValueEx(k, _WEBRTC_POLICY_VALUE)[0])
dword_ok = val == _WEBRTC_BLOCK_VALUE
if not dword_ok:
missing.append(f"{name} DWORD={val} (need {_WEBRTC_BLOCK_VALUE})")
except OSError:
pass
# Check modern REG_SZ
try:
val_str = str(winreg.QueryValueEx(k, _WEBRTC_POLICY_STR_KEY)[0])
str_ok = val_str == _WEBRTC_BLOCK_STR_VALUE
if not str_ok:
missing.append(f"{name} REG_SZ={val_str!r} (need {_WEBRTC_BLOCK_STR_VALUE!r})")
except OSError:
pass
if dword_ok or str_ok:
found.append(name)
elif not dword_ok and not str_ok:
missing.append(f"{name}: no WebRTC policy keys present")
except OSError:
continue # key not present at all — browser not installed or not policy-managed
if found:
return True, "Policy set: " + ", ".join(found)
if missing:
return False, "Not set: " + "; ".join(missing)
return False, "No Chrome/Edge/Chromium policy keys found (browsers may not be installed)"
# ─────────────────────────────────────────────────────────────────────────────
# 2. Firefox managed profile prefs
# ─────────────────────────────────────────────────────────────────────────────
def check_firefox_webrtc_prefs(profile_dir: Path) -> tuple[bool, str]:
"""Verify our written user.js contains the required WebRTC-disabling prefs."""
user_js = profile_dir / "user.js"
if not user_js.is_file():
return False, f"user.js not found at {profile_dir} — profile not yet initialized"
try:
content = user_js.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return False, f"Cannot read user.js: {e}"
missing: list[str] = []
for pref, expected_val in _REQUIRED_WEBRTC_PREFS.items():
# Look for: user_pref("pref.name", value);
needle = f'"{pref}", {expected_val}'
if needle not in content:
missing.append(pref)
if missing:
return False, "Missing in user.js: " + ", ".join(missing)
return True, "All WebRTC prefs present in managed profile"
# ─────────────────────────────────────────────────────────────────────────────
# 3. STUN server UDP reachability
# ─────────────────────────────────────────────────────────────────────────────
def _probe_stun_udp(host: str, port: int, timeout: float = 2.5) -> bool:
"""
Send a minimal STUN Binding Request over UDP and wait for a response.
Returns True if we get *any* response (STUN or ICMP unreachable).
"""
# Minimal STUN Binding Request (RFC 5389)
msg = (
b"\x00\x01" # Message Type: Binding Request
b"\x00\x00" # Message Length: 0
b"\x21\x12\xa4\x42" # Magic Cookie
+ b"\x00" * 12 # Transaction ID (12 bytes)
)
try:
ip = socket.gethostbyname(host)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
s.sendto(msg, (ip, port))
data, _ = s.recvfrom(512)
s.close()
return len(data) > 0
except socket.timeout:
return False
except Exception:
return False
def check_stun_reachability(timeout: float = 3.0) -> tuple[bool, str]:
"""
Returns (reachable, detail).
reachable = True means STUN servers ARE reachable → WebRTC COULD leak.
"""
reachable: list[str] = []
for host, port in _STUN_HOSTS[:3]:
try:
if _probe_stun_udp(host, port, timeout):
reachable.append(f"{host}:{port}")
except Exception:
continue
if reachable:
return True, "STUN reachable: " + ", ".join(reachable) + " — WebRTC UDP is NOT blocked"
return False, "STUN servers unreachable — WebRTC UDP appears blocked (good)"
# ─────────────────────────────────────────────────────────────────────────────
# Composite scan
# ─────────────────────────────────────────────────────────────────────────────
def run_webrtc_check(profile_dir: Path | None = None) -> WebRtcCheckResult:
"""
Run all WebRTC leak surface checks.
profile_dir: path to the managed Firefox profile (to verify user.js).
"""
res = WebRtcCheckResult()
# 1. Chrome/Edge policy
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)
# 2. Firefox profile prefs
if profile_dir is not None:
fx_ok, fx_detail = check_firefox_webrtc_prefs(profile_dir)
res.firefox_prefs_ok = fx_ok
res.firefox_prefs_detail = fx_detail
res.add("Firefox managed profile WebRTC prefs", fx_ok, fx_detail)
else:
res.add("Firefox managed profile WebRTC prefs", False,
"Profile not specified — not checked")
# 3. STUN UDP reachability
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)
# Overall verdict
res.any_leak_risk = bool(res.issues)
return res

View File

@@ -0,0 +1,107 @@
"""Windows version / PowerShell capability probe.
Used by privacy features to log a single clear reason when a function silently
no-ops on older Windows Server hosts (Server 2008 R2 ships PowerShell 2.0 which
does not have ``Get-NetAdapter`` / ``Set-NetAdapter`` / ``Disable-NetAdapterBinding``
/ ``Get-DnsClientServerAddress`` / ``Rename-Computer``).
"""
from __future__ import annotations
import logging
import platform
import subprocess
import sys
from dataclasses import dataclass
from functools import lru_cache
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class WinCompat:
release: str # e.g. "10", "2012ServerR2"
build: int # major build number
powershell_major: int # 0 = PowerShell not detected
has_net_cmdlets: bool # Get-NetAdapter etc. (PS 3.0+ on Server 2012+)
has_defender: bool # Add-MpPreference cmdlet exists
def summary(self) -> str:
if sys.platform != "win32":
return (
f"{platform.system()} release={self.release} "
f"PowerShell={self.powershell_major}.x "
"Windows-only privacy controls disabled"
)
return (
f"Windows release={self.release} build={self.build} "
f"PowerShell={self.powershell_major}.x "
f"NetAdapter cmdlets={'yes' if self.has_net_cmdlets else 'no'} "
f"Defender={'yes' if self.has_defender else 'no'}"
)
def _powershell_major() -> int:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"$PSVersionTable.PSVersion.Major"],
capture_output=True,
text=True,
timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
out = (r.stdout or "").strip().splitlines()[-1] if r.stdout else ""
return int(out) if out.isdigit() else 0
except Exception:
return 0
def _has_cmdlet(name: str) -> bool:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
f"if (Get-Command {name} -ErrorAction SilentlyContinue) "
f"{{ 'yes' }} else {{ 'no' }}"],
capture_output=True,
text=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return "yes" in (r.stdout or "").lower()
except Exception:
return False
@lru_cache(maxsize=1)
def probe() -> WinCompat:
"""Cached host probe. Cheap on subsequent calls."""
if sys.platform != "win32":
info = WinCompat(
release=platform.release(),
build=0,
powershell_major=0,
has_net_cmdlets=False,
has_defender=False,
)
log.info("Compat probe: %s", info.summary())
return info
release = platform.release()
try:
build = int(platform.version().split(".")[-1])
except Exception:
build = 0
ps = _powershell_major()
# PowerShell >= 3.0 is the gate for the modern Net* cmdlets that all the
# privacy features rely on. Anything older (Server 2008 R2 RTM) only has
# PS 2.0 and needs WMI / netsh / ipconfig fallbacks.
has_net = ps >= 3 and _has_cmdlet("Get-NetAdapter")
has_def = _has_cmdlet("Add-MpPreference")
info = WinCompat(
release=release,
build=build,
powershell_major=ps,
has_net_cmdlets=has_net,
has_defender=has_def,
)
log.info("WinCompat probe: %s", info.summary())
return info

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from .paths import app_data_dir
TASK_NAME = "ProxyChainManagerAutoStart"
def _write_launcher_cmd() -> Path:
"""Create a stable launcher script so Task Scheduler does not need fragile quoting."""
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
body = f'@echo off\r\nstart "" /B "{exe}"\r\n'
else:
py = Path(sys.executable).resolve()
if py.name.lower() == "python.exe":
cand = py.with_name("pythonw.exe")
if cand.is_file():
py = cand
root = Path(__file__).resolve().parent.parent
run_py = (root / "run.py").resolve()
body = f'@echo off\r\n"{py}" "{run_py}"\r\n'
p = app_data_dir() / "launch_proxy_chain_manager.cmd"
p.write_text(body, encoding="utf-8")
return p
def install_logon_task() -> tuple[bool, str]:
launcher = _write_launcher_cmd()
tr = str(launcher)
try:
r = subprocess.run(
[
"schtasks",
"/Create",
"/F",
"/TN",
TASK_NAME,
"/TR",
tr,
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
],
capture_output=True,
text=True,
timeout=60,
)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or ("OK" if r.returncode == 0 else "Failed")
except Exception as e:
return False, str(e)
def uninstall_logon_task() -> tuple[bool, str]:
try:
r = subprocess.run(
["schtasks", "/Delete", "/F", "/TN", TASK_NAME],
capture_output=True,
text=True,
timeout=60,
)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or ("OK" if r.returncode == 0 else "Failed")
except Exception as e:
return False, str(e)
def task_exists() -> bool:
try:
r = subprocess.run(
["schtasks", "/Query", "/TN", TASK_NAME],
capture_output=True,
text=True,
timeout=30,
)
return r.returncode == 0
except Exception:
return False

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
customtkinter==5.2.2
httpx[socks]==0.28.1
pystray==0.19.5
pillow==12.2.0
cryptography==48.0.0

11
run.py Normal file
View File

@@ -0,0 +1,11 @@
"""Launcher for development (pythonw run.py) so scheduled task can find a stable path."""
import sys
from proxy_chain_manager import __version__
from proxy_chain_manager.app import main
if __name__ == "__main__":
if "--version" in sys.argv or "-V" in sys.argv:
print(f"Proxy God {__version__}")
sys.exit(0)
main()

3
run_mac.command Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$DIR/scripts/run_proxy_god.sh"

5
scripts/app_launcher.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
exec "$ROOT/scripts/run_proxy_god.sh"

38
scripts/find_tk_python.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
candidates=(
"${PYTHON:-}"
"/usr/local/bin/python3"
"/Library/Frameworks/Python.framework/Versions/Current/bin/python3"
"/Library/Frameworks/Python.framework/Versions/3.13/bin/python3"
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3"
"/usr/bin/python3"
"/opt/homebrew/bin/python3"
"python3"
)
seen=""
for py in "${candidates[@]}"; do
[[ -n "$py" ]] || continue
if [[ "$py" != /* ]]; then
resolved="$(command -v "$py" 2>/dev/null || true)"
else
resolved="$py"
fi
[[ -x "$resolved" ]] || continue
case ":$seen:" in
*":$resolved:"*) continue ;;
esac
seen="$seen:$resolved"
if "$resolved" - <<'PY' >/dev/null 2>&1
import tkinter
PY
then
printf '%s\n' "$resolved"
exit 0
fi
done
echo "No Python with tkinter support found. Install Python from python.org or a Tk-enabled Python runtime." >&2
exit 1

26
scripts/run_proxy_god.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
TK_PY="$("$ROOT/scripts/find_tk_python.sh")"
needs_rebuild=0
if [[ ! -x ".venv/bin/python" ]]; then
needs_rebuild=1
elif ! ".venv/bin/python" - <<'PY' >/dev/null 2>&1
import tkinter
PY
then
needs_rebuild=1
fi
if [[ "$needs_rebuild" == "1" ]]; then
rm -rf .venv
"$TK_PY" -m venv .venv
fi
".venv/bin/python" -m pip install --upgrade pip >/dev/null
".venv/bin/python" -m pip install -r requirements.txt >/dev/null
exec ".venv/bin/python" run.py

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests package

48
tests/test_ban_tester.py Normal file
View File

@@ -0,0 +1,48 @@
"""Heuristic tests for ban_tester."""
from __future__ import annotations
import unittest
from proxy_chain_manager import ban_tester
class TestBannedBodyHints(unittest.TestCase):
def test_normal_page_is_not_banned(self) -> None:
self.assertFalse(ban_tester._looks_banned_body("Welcome to my homepage"))
self.assertFalse(ban_tester._looks_banned_body("<html><body>hello</body></html>"))
def test_naked_captcha_word_is_not_banned(self) -> None:
# 'captcha' alone is too common (privacy policies, login pages) —
# earlier audit removed it from hints.
self.assertFalse(ban_tester._looks_banned_body(
"We use a captcha on the signup page to prevent bots."
))
def test_captcha_required_phrase_is_banned(self) -> None:
self.assertTrue(ban_tester._looks_banned_body("Captcha required to continue."))
self.assertTrue(ban_tester._looks_banned_body("Complete the captcha below."))
def test_access_denied_is_banned(self) -> None:
self.assertTrue(ban_tester._looks_banned_body("Access denied"))
self.assertTrue(ban_tester._looks_banned_body("ACCESS DENIED — 403"))
def test_cloudflare_ray_is_banned(self) -> None:
self.assertTrue(ban_tester._looks_banned_body("Cloudflare Ray ID: abc123"))
def test_empty_body_is_not_banned(self) -> None:
self.assertFalse(ban_tester._looks_banned_body(""))
self.assertFalse(ban_tester._looks_banned_body(None)) # type: ignore[arg-type]
class TestSiteCategories(unittest.TestCase):
def test_all_category_includes_known_sites(self) -> None:
all_urls = {u for _, u in ban_tester.SITE_CATEGORIES["All"]}
self.assertTrue(len(all_urls) > 5)
def test_categories_present(self) -> None:
for key in ("Social / General", "Shopping", "Crypto Exchanges", "DNS Providers", "All"):
self.assertIn(key, ban_tester.SITE_CATEGORIES)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,105 @@
"""Round-trip / migrate / sanitize tests for config.Settings."""
from __future__ import annotations
import json
import unittest
from dataclasses import asdict
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from proxy_chain_manager import config
class TestSanitize(unittest.TestCase):
def test_clamps_chain_length(self) -> None:
s = config.Settings()
s.chain_length = 99
s, changed = config.sanitize_settings(s)
self.assertTrue(changed)
self.assertEqual(s.chain_length, 8)
def test_chain_length_string_resets_to_default(self) -> None:
s = config.Settings()
s.chain_length = "not a number" # type: ignore[assignment]
s, changed = config.sanitize_settings(s)
self.assertTrue(changed)
self.assertEqual(s.chain_length, 3)
def test_local_port_clamped(self) -> None:
s = config.Settings()
s.local_port = 99999
s, changed = config.sanitize_settings(s)
self.assertTrue(changed)
self.assertLessEqual(s.local_port, 65535)
def test_http_source_url_rejected(self) -> None:
# _is_safe_https_url should strip plaintext HTTP sources.
s = config.Settings()
s.sources = ["http://insecure.example/list.json"]
s, _ = config.sanitize_settings(s)
self.assertTrue(all(u.startswith("https://") for u in s.sources))
def test_rfc1918_source_url_rejected(self) -> None:
s = config.Settings()
s.sources = [
"https://192.168.1.1/list.json",
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
]
s, _ = config.sanitize_settings(s)
for u in s.sources:
# rfc1918 host must be gone.
self.assertNotIn("192.168.", u)
class TestMigrate(unittest.TestCase):
def test_unversioned_dict_gets_versioned(self) -> None:
raw = {"chain_length": 4}
out = config.migrate(raw)
self.assertEqual(out["settings_version"], config.SETTINGS_SCHEMA_VERSION)
self.assertEqual(out["chain_length"], 4)
def test_already_current_passthrough(self) -> None:
raw = {"settings_version": config.SETTINGS_SCHEMA_VERSION, "chain_length": 4}
out = config.migrate(raw)
self.assertEqual(out["settings_version"], config.SETTINGS_SCHEMA_VERSION)
class TestLoadSaveRoundTrip(unittest.TestCase):
def test_save_then_load_preserves_values(self) -> None:
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with patch.object(config, "app_data_dir", return_value=tmp_path):
s = config.Settings()
s.chain_length = 5
s.local_port = 19191
s.kill_switch_enabled = False
config.save_settings(s)
loaded = config.load_settings()
self.assertEqual(loaded.chain_length, 5)
self.assertEqual(loaded.local_port, 19191)
self.assertFalse(loaded.kill_switch_enabled)
def test_corrupt_settings_backed_up_and_defaults_returned(self) -> None:
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
(tmp_path / "settings.json").write_text("{ not json", encoding="utf-8")
with patch.object(config, "app_data_dir", return_value=tmp_path):
loaded = config.load_settings()
self.assertEqual(loaded.chain_length, config.Settings().chain_length)
self.assertTrue((tmp_path / "settings.json.corrupt").is_file())
def test_save_writes_backup_before_overwriting(self) -> None:
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with patch.object(config, "app_data_dir", return_value=tmp_path):
config.save_settings(config.Settings())
# Mutate + save again — .bak should now exist.
s = config.load_settings()
s.chain_length = 7
config.save_settings(s)
self.assertTrue((tmp_path / "settings.json.bak").is_file())
if __name__ == "__main__":
unittest.main()

26
tests/test_fail_closed.py Normal file
View File

@@ -0,0 +1,26 @@
"""Fail-closed leak-detection contract tests."""
from __future__ import annotations
import unittest
from proxy_chain_manager.leak_detect import is_chain_leak, leak_reason
class TestFailClosed(unittest.TestCase):
def test_unknown_real_ip_is_leak(self) -> None:
"""When the direct IP could not be determined we cannot prove the
chain is forwarding — the README promises fail-closed behavior."""
self.assertTrue(is_chain_leak("5.6.7.8", None, vpn_active=False))
self.assertTrue(is_chain_leak("5.6.7.8", None, vpn_active=True))
def test_unknown_exit_ip_is_leak(self) -> None:
self.assertTrue(is_chain_leak(None, "1.2.3.4", vpn_active=False))
def test_leak_reason_explains_unknown_direct_ip(self) -> None:
msg = leak_reason("5.6.7.8", None, vpn_active=False)
self.assertIn("direct IP", msg)
self.assertIn("fail-closed", msg.lower())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,42 @@
"""Non-destructive tests for firewall helpers.
These DO NOT call netsh — they only exercise pure functions / branches that
should never modify the system firewall state.
"""
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch
from proxy_chain_manager import firewall
@unittest.skipUnless(sys.platform == "win32", "Windows-only firewall API")
class TestEmergencyDisengageGuard(unittest.TestCase):
def test_no_op_when_not_engaged(self) -> None:
"""emergency_disengage() must NOT touch firewall state when our rules
are not present (the audit fix to C-08)."""
called = []
with patch.object(firewall, "is_engaged", return_value=False), \
patch.object(firewall, "_set_outbound_policy",
side_effect=lambda *_a, **_k: called.append("policy")), \
patch.object(firewall, "_delete_rules",
side_effect=lambda *_a, **_k: called.append("delete")):
firewall.emergency_disengage()
self.assertEqual(called, [])
def test_runs_disengage_when_engaged(self) -> None:
called = []
with patch.object(firewall, "is_engaged", return_value=True), \
patch.object(firewall, "_set_outbound_policy",
side_effect=lambda *_a, **_k: called.append("policy")), \
patch.object(firewall, "_delete_rules",
side_effect=lambda *_a, **_k: called.append("delete")):
firewall.emergency_disengage()
self.assertIn("policy", called)
self.assertIn("delete", called)
if __name__ == "__main__":
unittest.main()

60
tests/test_gost_util.py Normal file
View File

@@ -0,0 +1,60 @@
"""Hash verify / sidecar tests for gost_util."""
from __future__ import annotations
import hashlib
import tempfile
import unittest
from pathlib import Path
from proxy_chain_manager import gost_util
class TestVerifyZipSha256(unittest.TestCase):
def test_matching_hash_passes(self) -> None:
data = b"hello world"
digest = hashlib.sha256(data).hexdigest()
gost_util._verify_zip_sha256(data, digest) # must not raise
def test_wrong_hash_raises(self) -> None:
data = b"hello world"
with self.assertRaises(RuntimeError) as ctx:
gost_util._verify_zip_sha256(data, "0" * 64)
self.assertIn("supply-chain", str(ctx.exception).lower())
def test_case_insensitive_match(self) -> None:
data = b"PROXYGOD"
digest = hashlib.sha256(data).hexdigest().upper()
gost_util._verify_zip_sha256(data, digest)
class TestExeHashSidecar(unittest.TestCase):
def test_sidecar_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / "gost.exe"
exe.write_bytes(b"\x90" * 1024)
digest = gost_util._hash_file(exe)
gost_util._write_pinned_exe_hash(exe, digest)
self.assertEqual(gost_util._read_pinned_exe_hash(exe), digest)
def test_missing_sidecar_returns_none(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / "gost.exe"
exe.write_bytes(b"x")
self.assertIsNone(gost_util._read_pinned_exe_hash(exe))
def test_hash_file_matches_python_hashlib(self) -> None:
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"some payload bytes")
f.flush()
p = Path(f.name)
try:
self.assertEqual(
gost_util._hash_file(p),
hashlib.sha256(b"some payload bytes").hexdigest(),
)
finally:
p.unlink(missing_ok=True)
if __name__ == "__main__":
unittest.main()

424
tests/test_proxy_god.py Normal file
View File

@@ -0,0 +1,424 @@
"""Automated checks for Proxy God core logic (no GUI). Run: python -m unittest discover -s tests -v"""
from __future__ import annotations
import asyncio
import unittest
from proxy_chain_manager.leak_detect import is_chain_leak, is_same_subnet
from proxy_chain_manager.config import (
Settings,
merge_proxy_credentials,
normalize_proxy_url,
parse_exit_proxy_input,
redact_proxy_url,
sanitize_settings,
split_proxy_for_edit,
)
from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries
from proxy_chain_manager.validator import (
check_chain_exit_ip,
check_https_tunnel,
get_direct_ip,
validate_proxies,
)
class TestSameNetwork(unittest.TestCase):
def test_exact_match(self) -> None:
self.assertTrue(is_same_subnet("1.2.3.4", "1.2.3.4"))
def test_same_slash16(self) -> None:
self.assertTrue(is_same_subnet("185.220.101.5", "185.220.200.9"))
def test_different_slash16(self) -> None:
self.assertFalse(is_same_subnet("185.220.101.5", "104.28.3.99"))
def test_none_safe(self) -> None:
self.assertFalse(is_same_subnet(None, "1.2.3.4"))
self.assertFalse(is_same_subnet("1.2.3.4", None))
def test_non_ipv4_safe(self) -> None:
self.assertFalse(is_same_subnet("not-an-ip", "1.2.3.4"))
class TestLeakDetect(unittest.TestCase):
def test_strict_no_vpn_same_ip(self) -> None:
self.assertTrue(is_chain_leak("1.2.3.4", "1.2.3.4", vpn_active=False))
def test_strict_no_vpn_different_ip(self) -> None:
self.assertFalse(is_chain_leak("5.6.7.8", "1.2.3.4", vpn_active=False))
def test_vpn_subnet_leak(self) -> None:
self.assertTrue(is_chain_leak("185.220.101.5", "185.220.200.9", vpn_active=True))
def test_vpn_different_subnet_ok(self) -> None:
self.assertFalse(is_chain_leak("104.28.3.99", "185.220.101.5", vpn_active=True))
class TestConfig(unittest.TestCase):
def test_normalize_proxy_url(self) -> None:
self.assertEqual(normalize_proxy_url(""), "")
self.assertEqual(normalize_proxy_url("1.2.3.4:8080"), "http://1.2.3.4:8080")
self.assertEqual(normalize_proxy_url("socks5://x:1"), "socks5://x:1")
def test_merge_split_proxy_auth(self) -> None:
merged = merge_proxy_credentials("http://1.2.3.4:8080", "user", "p:ass")
self.assertIn("user", merged)
self.assertIn("p%3Aass", merged)
base, u, pw = split_proxy_for_edit(merged)
self.assertEqual(base, "http://1.2.3.4:8080")
self.assertEqual(u, "user")
self.assertEqual(pw, "p:ass")
def test_merge_preserves_embedded_auth_when_fields_empty(self) -> None:
raw = normalize_proxy_url("http://x:y@9.9.9.9:12")
self.assertEqual(merge_proxy_credentials(raw, "", ""), raw)
def test_redact_proxy_url(self) -> None:
r = redact_proxy_url("http://alice:secret@proxy.example:8888")
self.assertIn("***:***@", r)
self.assertIn("proxy.example", r)
self.assertNotIn("secret", r)
self.assertNotIn("alice", r)
def test_sanitize_restores_empty_sources(self) -> None:
s = Settings()
s.sources = []
s2, changed = sanitize_settings(s)
self.assertTrue(changed)
self.assertGreater(len(s2.sources), 0)
def test_parse_exit_host_port_only_defaults_to_socks5(self) -> None:
base, u, pw = parse_exit_proxy_input("1.2.3.4:1080")
self.assertEqual(base, "socks5://1.2.3.4:1080")
self.assertEqual(u, "")
self.assertEqual(pw, "")
def test_parse_exit_colon_quadruple(self) -> None:
base, u, pw = parse_exit_proxy_input("1.2.3.4:1080:alice:s3cret")
self.assertEqual(base, "socks5://1.2.3.4:1080")
self.assertEqual(u, "alice")
self.assertEqual(pw, "s3cret")
def test_parse_exit_password_with_colon_kept_intact(self) -> None:
base, u, pw = parse_exit_proxy_input("h:9:u:a:b:c")
self.assertEqual(base, "socks5://h:9")
self.assertEqual(u, "u")
self.assertEqual(pw, "a:b:c")
def test_parse_exit_at_form_no_scheme(self) -> None:
base, u, pw = parse_exit_proxy_input("alice:secret@1.2.3.4:1080")
self.assertEqual(base, "socks5://1.2.3.4:1080")
self.assertEqual(u, "alice")
self.assertEqual(pw, "secret")
def test_parse_exit_respects_explicit_scheme(self) -> None:
base, u, pw = parse_exit_proxy_input("http://x:y@9.9.9.9:8080")
self.assertEqual(base, "http://9.9.9.9:8080")
self.assertEqual(u, "x")
self.assertEqual(pw, "y")
def test_parse_exit_whitespace_form(self) -> None:
base, u, pw = parse_exit_proxy_input("1.2.3.4:1080 alice s3cret")
self.assertEqual(base, "socks5://1.2.3.4:1080")
self.assertEqual(u, "alice")
self.assertEqual(pw, "s3cret")
def test_parse_exit_empty(self) -> None:
self.assertEqual(parse_exit_proxy_input(" "), ("", "", ""))
def test_sanitize_clamps_extremes(self) -> None:
s = Settings(
chain_length=0,
health_check_seconds=999999,
full_refresh_seconds=10,
validation_timeout_seconds=900.0,
)
s, changed = sanitize_settings(s)
self.assertTrue(changed)
self.assertEqual(s.chain_length, 1)
self.assertEqual(s.health_check_seconds, 3600)
self.assertEqual(s.full_refresh_seconds, 60)
self.assertEqual(s.validation_timeout_seconds, 120.0)
class TestFetcher(unittest.TestCase):
def test_normalize_entries_empty(self) -> None:
self.assertEqual(normalize_entries([], False), [])
def test_fetch_smoke(self) -> None:
"""One real HTTP GET — may fail offline; then skip assertion."""
try:
rows = fetch_proxy_json(
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
timeout=25.0,
)
except Exception:
self.skipTest("network unavailable")
self.assertIsInstance(rows, list)
if rows:
self.assertTrue(isinstance(rows[0], dict))
class TestValidator(unittest.TestCase):
def test_validate_empty_list_returns_fast(self) -> None:
async def run() -> list[str]:
return await validate_proxies(
[],
"https://api.ipify.org?format=json",
8,
5.0,
)
self.assertEqual(asyncio.run(run()), [])
def test_validate_dead_proxy_fast(self) -> None:
async def run() -> list[str]:
return await validate_proxies(
["http://127.0.0.1:1"],
"https://api.ipify.org?format=json",
1,
3.0,
)
self.assertEqual(asyncio.run(run()), [])
def test_validate_early_exit_target(self) -> None:
"""Once target is reached remaining tasks should be skipped (all dead here → no early exit but no crash)."""
async def run() -> list[str]:
return await validate_proxies(
["http://127.0.0.1:1"] * 10,
"https://api.ipify.org?format=json",
4,
2.0,
target=2, # target unreachable — should still return [] cleanly
)
result = asyncio.run(run())
self.assertEqual(result, [])
def test_get_direct_ip_network(self) -> None:
async def run() -> str | None:
return await get_direct_ip("https://api.ipify.org?format=json", 12.0)
ip = asyncio.run(run())
if ip is None:
self.skipTest("could not reach ipify (offline or blocked)")
self.assertGreater(len(ip), 4)
def test_check_https_tunnel_invalid_proxy_quick(self) -> None:
async def run() -> tuple[bool, str]:
return await check_https_tunnel("http://127.0.0.1:1", timeout_seconds=4.0)
ok, msg = asyncio.run(run())
self.assertFalse(ok)
self.assertTrue(msg)
def test_check_chain_exit_ip_invalid_proxy_quick(self) -> None:
async def run() -> str | None:
return await check_chain_exit_ip(
"http://127.0.0.1:1",
"https://api.ipify.org?format=json",
8.0,
)
self.assertIsNone(asyncio.run(run()))
class TestBrowserPersona(unittest.TestCase):
def test_blend_persona_relaxes_block_flags(self) -> None:
from proxy_chain_manager.browser_profile import (
FirefoxHardening, apply_persona, build_user_js,
)
h = FirefoxHardening(
persona_key="blend_windows_chrome",
resist_fingerprinting=True,
first_party_isolation=True,
strict_tracking_protection=True,
timezone_utc=True,
)
applied = apply_persona(h)
self.assertFalse(applied.resist_fingerprinting)
self.assertFalse(applied.first_party_isolation)
self.assertFalse(applied.strict_tracking_protection)
self.assertFalse(applied.timezone_utc)
self.assertIsNotNone(applied.persona)
# And the user.js carries a spoofed UA when blend is selected.
js = build_user_js("127.0.0.1", 18888, h)
self.assertIn("general.useragent.override", js)
self.assertIn("Chrome/124", js)
def test_hardened_persona_keeps_block_flags(self) -> None:
from proxy_chain_manager.browser_profile import FirefoxHardening, apply_persona
h = FirefoxHardening(
persona_key="hardened",
resist_fingerprinting=False, # should be forced True
first_party_isolation=False,
)
applied = apply_persona(h)
self.assertTrue(applied.resist_fingerprinting)
self.assertTrue(applied.first_party_isolation)
self.assertTrue(applied.strict_tracking_protection)
self.assertIsNone(applied.persona)
def test_cookie_behavior_mapping(self) -> None:
from proxy_chain_manager.browser_identity import (
cookie_behavior_value, cookie_session_only,
)
self.assertEqual(cookie_behavior_value("accept_all"), 0)
self.assertEqual(cookie_behavior_value("block_third_party"), 1)
self.assertEqual(cookie_behavior_value("block_third_party_trackers"), 4)
self.assertEqual(cookie_behavior_value("session_only"), 0)
self.assertEqual(cookie_behavior_value("block_all"), 2)
self.assertTrue(cookie_session_only("session_only"))
self.assertFalse(cookie_session_only("accept_all"))
def test_persona_env_returns_tz_for_blend(self) -> None:
from proxy_chain_manager.browser_profile import FirefoxHardening, persona_env
env = persona_env(FirefoxHardening(persona_key="blend_windows_chrome"))
self.assertIn("TZ", env)
self.assertTrue(env["TZ"])
def test_persona_env_empty_for_hardened(self) -> None:
from proxy_chain_manager.browser_profile import FirefoxHardening, persona_env
self.assertEqual(persona_env(FirefoxHardening(persona_key="hardened")), {})
class TestPrivacyLan(unittest.TestCase):
def test_snapshot_round_trip(self) -> None:
from proxy_chain_manager.privacy_lan import (
LanSnapshot, snapshot_from_json, snapshot_to_json,
)
s = LanSnapshot(
netbios_per_interface={"{a-b-c}": 0, "{d-e-f}": 2},
llmnr_present=True,
llmnr_prev_value=1,
mdns_present=False,
mdns_prev_value=None,
)
round_tripped = snapshot_from_json(snapshot_to_json(s))
self.assertIsNotNone(round_tripped)
assert round_tripped is not None
self.assertEqual(round_tripped.netbios_per_interface, s.netbios_per_interface)
self.assertEqual(round_tripped.llmnr_present, s.llmnr_present)
self.assertEqual(round_tripped.llmnr_prev_value, s.llmnr_prev_value)
self.assertEqual(round_tripped.mdns_present, s.mdns_present)
self.assertEqual(round_tripped.mdns_prev_value, s.mdns_prev_value)
def test_lan_status_returns_keys(self) -> None:
from proxy_chain_manager.privacy_lan import lan_status
st = lan_status()
self.assertIn("netbios", st)
self.assertIn("llmnr", st)
self.assertIn("mdns", st)
class TestLeakAuditPure(unittest.TestCase):
def test_categorize_exit_private(self) -> None:
from proxy_chain_manager.leak_audit import _categorize_exit
self.assertEqual(_categorize_exit("10.0.0.5"), "PRIVATE — chain broken")
self.assertEqual(_categorize_exit("192.168.1.1"), "PRIVATE — chain broken")
self.assertEqual(_categorize_exit("172.20.5.5"), "PRIVATE — chain broken")
def test_categorize_exit_loopback(self) -> None:
from proxy_chain_manager.leak_audit import _categorize_exit
self.assertEqual(_categorize_exit("127.0.0.1"), "LOOPBACK — chain broken")
def test_categorize_exit_public(self) -> None:
from proxy_chain_manager.leak_audit import _categorize_exit
self.assertEqual(_categorize_exit("104.28.3.99"), "public")
self.assertEqual(_categorize_exit("8.8.8.8"), "public")
def test_categorize_exit_none(self) -> None:
from proxy_chain_manager.leak_audit import _categorize_exit
self.assertEqual(_categorize_exit(None), "unknown")
def test_audit_finding_aggregation(self) -> None:
from proxy_chain_manager.leak_audit import AuditReport
rep = AuditReport()
rep.add("X", True, "ok")
self.assertTrue(rep.overall_ok)
rep.add("Y", False, "bad", "note")
self.assertFalse(rep.overall_ok)
self.assertEqual(len(rep.findings), 2)
class TestExitIntelGeo(unittest.TestCase):
def test_parse_ipapi_lat_lon(self) -> None:
from proxy_chain_manager.exit_intel import _parse_ipapi
out = _parse_ipapi(
{
"status": "success",
"query": "8.8.8.8",
"country": "United States",
"countryCode": "US",
"regionName": "Virginia",
"city": "Ashburn",
"lat": 39.03,
"lon": -77.5,
"timezone": "America/New_York",
"isp": "Google LLC",
"org": "Google Public DNS",
"as": "AS15169 Google LLC",
"mobile": False,
"proxy": False,
"hosting": True,
}
)
self.assertTrue(out.ok)
self.assertAlmostEqual(out.lat or 0, 39.03)
self.assertAlmostEqual(out.lon or 0, -77.5)
def test_parse_ipwhois_lat_lon(self) -> None:
from proxy_chain_manager.exit_intel import _parse_ipwhois
out = _parse_ipwhois(
{
"success": True,
"ip": "1.1.1.1",
"country": "Australia",
"country_code": "AU",
"city": "Sydney",
"region": "New South Wales",
"latitude": -33.86,
"longitude": 151.2,
"timezone": {"id": "Australia/Sydney"},
"connection": {"asn": 13335, "org": "Cloudflare", "isp": "Cloudflare"},
}
)
self.assertTrue(out.ok)
self.assertAlmostEqual(out.lat or 0, -33.86)
self.assertAlmostEqual(out.lon or 0, 151.2)
class TestChainMap(unittest.TestCase):
def test_extract_hop_host(self) -> None:
from proxy_chain_manager.chain_map import extract_hop_host
self.assertEqual(extract_hop_host("http://203.0.113.10:8080"), "203.0.113.10")
self.assertEqual(extract_hop_host("socks5://proxy.example:1080"), "proxy.example")
self.assertIsNone(extract_hop_host(""))
def test_project_corners(self) -> None:
from proxy_chain_manager.chain_map import _project
x, y = _project(0, 0, 360, 180, 0)
self.assertAlmostEqual(x, 180.0)
self.assertAlmostEqual(y, 90.0)
def test_render_chain_map_with_points(self) -> None:
from proxy_chain_manager.chain_map import MapPoint, render_chain_map
pts = [
MapPoint("you", "YOU", 40.0, -74.0, detail="NYC"),
MapPoint("hop", "H1", 51.5, -0.1, detail="London"),
MapPoint("exit", "EXIT", 35.6, 139.7, detail="Tokyo"),
]
img = render_chain_map(pts, width=640, height=200, status="healthy")
self.assertEqual(img.size, (640, 200))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,48 @@
"""Round-trip tests for the DPAPI-backed secrets store.
These are skipped automatically on non-Windows so the test suite stays cross-
platform. On Windows, DPAPI is part of the OS — no extra deps required.
"""
from __future__ import annotations
import sys
import unittest
from proxy_chain_manager import secrets_store
@unittest.skipUnless(sys.platform == "win32", "DPAPI only on Windows")
class TestSecretsStore(unittest.TestCase):
def test_empty_passthrough(self) -> None:
self.assertEqual(secrets_store.encrypt(""), "")
self.assertEqual(secrets_store.decrypt(""), "")
def test_legacy_plaintext_passthrough(self) -> None:
# Anything without the DPAPI: prefix decrypts to itself so migration
# from plaintext files is automatic on first read.
self.assertEqual(secrets_store.decrypt("legacy_pw"), "legacy_pw")
def test_round_trip_ascii(self) -> None:
secret = "hunter2!"
token = secrets_store.encrypt(secret)
self.assertTrue(token.startswith("DPAPI:v1:"))
self.assertEqual(secrets_store.decrypt(token), secret)
def test_round_trip_unicode(self) -> None:
secret = "пароль · 🔐 · café"
token = secrets_store.encrypt(secret)
self.assertEqual(secrets_store.decrypt(token), secret)
def test_double_encrypt_idempotent(self) -> None:
token = secrets_store.encrypt("once")
again = secrets_store.encrypt(token)
self.assertEqual(token, again)
def test_is_encrypted_helper(self) -> None:
self.assertFalse(secrets_store.is_encrypted(""))
self.assertFalse(secrets_store.is_encrypted("plaintext"))
self.assertTrue(secrets_store.is_encrypted(secrets_store.encrypt("x")))
if __name__ == "__main__":
unittest.main()

60
winreg.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tiny non-Windows winreg compatibility shim.
The upstream Proxy God code is Windows-first and imports ``winreg`` in several
modules. macOS does not ship that module. This shim keeps imports working and
makes registry operations behave like unavailable keys.
"""
from __future__ import annotations
HKEY_CURRENT_USER = object()
HKEY_LOCAL_MACHINE = object()
KEY_QUERY_VALUE = 0x0001
KEY_SET_VALUE = 0x0002
KEY_ALL_ACCESS = 0xF003F
REG_BINARY = 3
REG_DWORD = 4
REG_SZ = 1
class HKEYType:
def __enter__(self) -> "HKEYType":
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
def OpenKey(*args, **kwargs) -> HKEYType:
raise OSError("winreg is unavailable on this platform")
def CreateKeyEx(*args, **kwargs) -> HKEYType:
raise OSError("winreg is unavailable on this platform")
def QueryValue(*args, **kwargs):
raise OSError("winreg is unavailable on this platform")
def QueryValueEx(*args, **kwargs):
raise OSError("winreg is unavailable on this platform")
def SetValueEx(*args, **kwargs) -> None:
raise OSError("winreg is unavailable on this platform")
def DeleteValue(*args, **kwargs) -> None:
raise OSError("winreg is unavailable on this platform")
def DeleteKey(*args, **kwargs) -> None:
raise OSError("winreg is unavailable on this platform")
def EnumValue(*args, **kwargs):
raise OSError("winreg is unavailable on this platform")
def EnumKey(*args, **kwargs):
raise OSError("winreg is unavailable on this platform")