first commit
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
28
Bundle/ProxyGodInfo.plist
Normal 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
84
CHANGELOG.md
Normal 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
20
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
14
LICENSE_STATUS.md
Normal 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
29
Makefile
Normal 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
|
||||||
28
Proxy God.app/Contents/Info.plist
Normal file
28
Proxy God.app/Contents/Info.plist
Normal 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>
|
||||||
5
Proxy God.app/Contents/MacOS/ProxyGod
Executable file
5
Proxy God.app/Contents/MacOS/ProxyGod
Executable 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
41
README.md
Normal 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
19
SECURITY.md
Normal 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
318
docs/AUDIT_FINDINGS.md
Normal 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 1–8 hops; UI/config says 2–8 in places | `README.md:48`, `config.py:167` |
|
||||||
|
| M-04 | No in-GUI emergency kill-switch disengage button | `firewall.py`, `app.py` |
|
||||||
|
| M-05 | `audit_device()` is OS-only but UI shows it under "Browser Fingerprint" | `fingerprint.py:299-383`, `app.py:2310-2313` |
|
||||||
|
| M-06 | Preflight card mentions "fingerprint" but no fingerprint check in `_PF_CHECKS` | `app.py:1775-1793` |
|
||||||
|
| M-07 | `browser_auto_relaunch` not tied to chain health flag | `app.py:2866-2871`, `config.py:205` |
|
||||||
|
| M-08 | No export/import encrypted settings/signup vault | — |
|
||||||
|
| M-09 | No persisted ban-test / audit history | `app.py` |
|
||||||
|
| M-10 | `prefer_elite` can empty pool with no GUI recovery wizard | `config.py:183`, `service.py:754-761` |
|
||||||
|
| M-11 | SOCKS5 remote DNS disabled in Firefox profile | `browser_profile.py:125` |
|
||||||
|
| M-12 | Signup presets have no ToS/disclaimer in UI | `signup_prep.py:37-160` |
|
||||||
|
| M-13 | No IPv6 chain path — IPv6 disable adapter-level only | `fingerprint.py:106-138` |
|
||||||
|
| M-14 | Tray red/yellow/green with no click-through to failed check detail | `tray.py`, `app.py:2841-2844` |
|
||||||
|
| M-15 | `CHANGELOG.md` claims DPAPI/Fernet — not implemented | `CHANGELOG.md:35-36` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build / Packaging / Deployment
|
||||||
|
|
||||||
|
| ID | Finding | Reference |
|
||||||
|
|----|---------|-----------|
|
||||||
|
| B-01 | No Authenticode signing | `ProxyChainManager.spec:70-71` |
|
||||||
|
| B-02 | UPX enabled — AV false positives risk | `ProxyChainManager.spec:66-67` |
|
||||||
|
| B-03 | `uac_admin=True` — every launch elevates | `ProxyChainManager.spec:72` |
|
||||||
|
| B-04 | PyInstaller not pinned in `requirements.txt` | `scripts/setup_and_build.ps1:39` |
|
||||||
|
| B-05 | Build script doesn't emit SHA256 sidecar | `scripts/setup_and_build.ps1:50-58` |
|
||||||
|
| B-06 | GOST not bundled — first run requires GitHub access | `gost_util.py:61-86` |
|
||||||
|
| B-07 | `__version__` static `1.0.0`, not tied to git tag | `__init__.py:3` |
|
||||||
|
| B-08 | CI does not run PyInstaller build | `.github/workflows/test.yml` |
|
||||||
|
| B-09 | No SBOM / `pip freeze` in release artifacts | — |
|
||||||
|
| B-10 | Defender exclusion adds whole `%LOCALAPPDATA%\ProxyChainManager` folder | `gost_util.py:29-45` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security / Privacy
|
||||||
|
|
||||||
|
| ID | Finding | Reference |
|
||||||
|
|----|---------|-----------|
|
||||||
|
| S-01 | Plaintext secrets at rest (proxy creds + signup passwords) | `config.py`, `signup_prep.py` |
|
||||||
|
| S-02 | WebRTC mis-hardening (wrong policy value) | `fingerprint.py` |
|
||||||
|
| S-03 | All httpx clients use `verify=False` — MITM risk | `validator.py:142`, `ban_tester.py:119`, `dns_leak.py:159` |
|
||||||
|
| S-04 | Signup passwords can appear in UI/log via clipboard actions | `app.py:2059-2062` |
|
||||||
|
| S-05 | Signup extension content script on all URLs | `signup_extension/manifest.json` |
|
||||||
|
| S-06 | Public proxy list sources — hostile infrastructure by design | `config.py:218-223` |
|
||||||
|
| S-07 | `cryptography` dependency unused — attack surface without benefit | `requirements.txt:5` |
|
||||||
|
| S-08 | Kill-switch + DNS allow rule = DNS bypass channel | `firewall.py:136-140` |
|
||||||
|
| S-09 | Forensic wipe deletes Recycle Bin without extra confirmation | `artifact_wipe.py:208-216` |
|
||||||
|
| S-10 | No secure deletion of signup JSON on uninstall | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI/UX Issues
|
||||||
|
|
||||||
|
| ID | Finding | Reference |
|
||||||
|
|----|---------|-----------|
|
||||||
|
| U-01 | Window close minimizes to tray — chain/firewall active silently | `app.py:2908` |
|
||||||
|
| U-02 | "Run as Admin" relaunch closes app — unsaved UI state lost | `app.py:347-350` |
|
||||||
|
| U-03 | No version/build SHA in About UI | `__init__.py:3` |
|
||||||
|
| U-04 | Cookie/persona menus reverse-lookup by label — fragile if labels change | `app.py:1321-1325` |
|
||||||
|
| U-05 | Hardened toggles visible even when blend persona overrides them | `browser_profile.py:42-65` |
|
||||||
|
| U-06 | Preflight "fingerprint" in subtitle but not in checklist | `app.py:1775-1793` |
|
||||||
|
| U-07 | Ban tester has no progress bar for 30+ sites | `ban_tester.py:222-260` |
|
||||||
|
| U-08 | Saved accounts list shows email + password in plain text | `app.py:2046-2049` |
|
||||||
|
| U-09 | Chain map silently skips hops when geo lookup fails — no unknown marker | `chain_map.py:156-166,322-328` |
|
||||||
|
| U-10 | `verbose_logs_var` affects UI handler only; file log stays INFO | `app.py:707-713` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Test Coverage Gaps
|
||||||
|
|
||||||
|
| ID | Finding |
|
||||||
|
|----|---------|
|
||||||
|
| T-01 | No tests for `service.py` (rotation, sticky, kill-switch) |
|
||||||
|
| T-02 | No tests for `firewall.py` / `emergency_disengage` |
|
||||||
|
| T-03 | No tests for `sysproxy.py` registry blob encoding |
|
||||||
|
| T-04 | No tests for GOST SHA256 mismatch path |
|
||||||
|
| T-05 | No tests for `dns_leak.run_dns_leak_test` verdict logic |
|
||||||
|
| T-06 | No tests for `webrtc_check` / `apply_webrtc_hardening` |
|
||||||
|
| T-07 | No tests for `BrowserSession` |
|
||||||
|
| T-08 | No tests for `signup_prep.install_signup_extension` |
|
||||||
|
| T-09 | No tests for `config.load_settings`/`migrate`/`sanitize` round-trip |
|
||||||
|
| T-10 | No tests for `ban_tester` heuristics |
|
||||||
|
| T-11 | No GUI/smoke tests for `app.py` (~2900 lines) |
|
||||||
|
| T-12 | `test_fetch_smoke` is network-dependent — can skip silently in CI |
|
||||||
|
| T-13 | No test that WebRTC policy value matches Chrome documentation |
|
||||||
|
| T-14 | No frozen-bundle (`_MEIPASS`) path tests in CI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Recommended Fix Priority
|
||||||
|
|
||||||
|
**P0 — Fix immediately:**
|
||||||
|
C-01, C-02, C-03, C-04, C-05, C-06, S-01, S-02, S-03, B-01
|
||||||
|
|
||||||
|
**P1 — Next sprint:**
|
||||||
|
C-07, C-10, E-01–E-06, E-11, E-22, E-25–E-27, M-04–M-07, M-11, B-04–B-06, T-01–T-05, T-09, U-01–U-03
|
||||||
|
|
||||||
|
**P2 — Backlog:**
|
||||||
|
All remaining E/M/B/U/T items, installer signing, metrics, coverage gates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Remediation log (2026-05-22 passes)
|
||||||
|
|
||||||
|
| ID | Status | Notes |
|
||||||
|
|----|--------|-------|
|
||||||
|
| C-01 | **Fixed** | `fingerprint.py`, `webrtc_check.py` — DWORD `3` + `WebRtcIPHandling` REG_SZ |
|
||||||
|
| C-02 | **Fixed** | `browser_launcher.py` — scoped `taskkill /PID` |
|
||||||
|
| C-04 | **Fixed** | `app.py` — `_final` scheduled after all `after(0)` callbacks |
|
||||||
|
| C-05 | **Fixed** | `app.py` preflight uses `is_chain_leak()` + VPN-aware `/16` |
|
||||||
|
| C-06 | **Fixed** | `service.py` — HTTPS CONNECT failure rotates chain (not healthy) |
|
||||||
|
| C-08 | **Fixed** | `firewall.py` — `emergency_disengage()` no-op unless `is_engaged()` |
|
||||||
|
| C-10 | **Fixed** | `app.py` — auto-save on launch is `pending` with empty password |
|
||||||
|
| E-06 | **Fixed** | `service.py` — log when pinned chain enabled but empty |
|
||||||
|
| E-11 | **Fixed** | `dns_leak.py`, `ban_tester.py` — restore socket timeout in `finally` |
|
||||||
|
| E-24 | **Fixed** | `config.py` — pool sources must be `https://` only |
|
||||||
|
| E-26 | **Fixed** | `ban_tester.py` — removed bare `"captcha"` hint; stricter phrases |
|
||||||
|
| E-27 | **Fixed** | `ban_tester.py` — dedupe by URL |
|
||||||
|
| M-06 / U-06 | **Fixed** | Preflight subtitle no longer claims “fingerprint” check |
|
||||||
|
| M-15 | **Fixed** | `CHANGELOG.md` — Fernet/DPAPI not implemented (clarified) |
|
||||||
|
| B-02 | **Fixed** | `ProxyChainManager.spec` — `upx=False` |
|
||||||
|
| leak_audit | **Fixed** | `_check_webrtc_policy()` accepts DWORD `3` or REG_SZ |
|
||||||
|
| (prior) | **Fixed** | CI, GOST zip SHA256, settings migration/backup, LICENSE, log rotation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Still needs to be fixed
|
||||||
|
|
||||||
|
Items below were **not** changed because they need design decisions, external tooling (certs, infra), or behavior that is not safe to guess.
|
||||||
|
|
||||||
|
### Critical / security (P0)
|
||||||
|
|
||||||
|
| ID | Why left open | Suggested direction |
|
||||||
|
|----|---------------|---------------------|
|
||||||
|
| **C-09** | Narrowing signup extension off `<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-02–E-05** | Sticky exit / leak semantics need a product rule (rotate vs warn) |
|
||||||
|
| **E-07–E-10** | Pool/pinned/exit edge cases need operator UX |
|
||||||
|
| **E-12–E-13** | DNS leak methodology (DoH vs system DNS) needs spec |
|
||||||
|
| **E-14 / S-08** | Kill-switch DNS allow rule is required for GOST hostname resolution |
|
||||||
|
| **E-16–E-18** | OS adapter / MAC spoof crash recovery |
|
||||||
|
| **E-21** | Boot task LIMITED vs admin kill-switch |
|
||||||
|
| **E-22** | Group Policy proxy-lock remediation (destructive — needs explicit operator consent) |
|
||||||
|
| **E-23** | Auto-rewrite of sanitized settings — needs UI toggle |
|
||||||
|
| **E-28–E-30** | Third-party intel APIs / map geo / GOST blacklist rules |
|
||||||
|
| **M-01–M-03, M-05, M-07–M-14** | README alignment, encrypted vault export, ban history, IPv6 chain path, tray click-through |
|
||||||
|
| **U-01, U-02, U-04, U-05, U-07–U-10** | Tray/minimize UX, admin-relaunch save state, cookie menu keys, password masking |
|
||||||
|
| **T-01–T-14** | Larger test-coverage program |
|
||||||
|
| **B-03, B-06–B-10** | UAC manifest tradeoff, GOST bundling, SBOM, Defender scope |
|
||||||
|
|
||||||
|
### Medium (P2)
|
||||||
|
|
||||||
|
Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS policy (`browser_profile.py:125` — likely intentional for Firefox+GOST).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Decisions held — accepted trade-offs
|
||||||
|
|
||||||
|
These items are **closed by design choice** (operator approved), not because they're hidden bugs.
|
||||||
|
|
||||||
|
| ID | Decision | Rationale |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| **C-09** | Keep signup extension on `<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
23
docs/MAINTENANCE.md
Normal 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
128
docs/OPERATOR_RUNBOOK.md
Normal 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
14
docs/PROJECT_HANDOFF.md
Normal 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.
|
||||||
12
docs/PROVENANCE_CHECKLIST.md
Normal file
12
docs/PROVENANCE_CHECKLIST.md
Normal 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
92
docs/RELEASE_PROCESS.md
Normal 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
20
docs/ROADMAP.md
Normal 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
14
docs/SECURITY_REVIEW.md
Normal 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.
|
||||||
BIN
docs/dashboard-screenshot.png
Normal file
BIN
docs/dashboard-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
3
proxy_chain_manager/__init__.py
Normal file
3
proxy_chain_manager/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Rotating proxy chain manager with a GOST backend."""
|
||||||
|
|
||||||
|
__version__ = "2.0.0-mac"
|
||||||
4
proxy_chain_manager/__main__.py
Normal file
4
proxy_chain_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3074
proxy_chain_manager/app.py
Normal file
3074
proxy_chain_manager/app.py
Normal file
File diff suppressed because it is too large
Load Diff
218
proxy_chain_manager/artifact_wipe.py
Normal file
218
proxy_chain_manager/artifact_wipe.py
Normal 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
|
||||||
221
proxy_chain_manager/ban_tester.py
Normal file
221
proxy_chain_manager/ban_tester.py
Normal 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)
|
||||||
327
proxy_chain_manager/browser_identity.py
Normal file
327
proxy_chain_manager/browser_identity.py
Normal 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_")
|
||||||
402
proxy_chain_manager/browser_launcher.py
Normal file
402
proxy_chain_manager/browser_launcher.py
Normal 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."
|
||||||
271
proxy_chain_manager/browser_profile.py
Normal file
271
proxy_chain_manager/browser_profile.py
Normal 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",
|
||||||
|
)
|
||||||
367
proxy_chain_manager/chain_map.py
Normal file
367
proxy_chain_manager/chain_map.py
Normal 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)
|
||||||
470
proxy_chain_manager/config.py
Normal file
470
proxy_chain_manager/config.py
Normal 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")
|
||||||
335
proxy_chain_manager/dns_leak.py
Normal file
335
proxy_chain_manager/dns_leak.py
Normal 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)}",
|
||||||
|
)
|
||||||
226
proxy_chain_manager/exit_intel.py
Normal file
226
proxy_chain_manager/exit_intel.py
Normal 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")
|
||||||
37
proxy_chain_manager/fetcher.py
Normal file
37
proxy_chain_manager/fetcher.py
Normal 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
|
||||||
416
proxy_chain_manager/fingerprint.py
Normal file
416
proxy_chain_manager/fingerprint.py
Normal 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],
|
||||||
|
)
|
||||||
191
proxy_chain_manager/firewall.py
Normal file
191
proxy_chain_manager/firewall.py
Normal 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)
|
||||||
289
proxy_chain_manager/gost_util.py
Normal file
289
proxy_chain_manager/gost_util.py
Normal 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)
|
||||||
41
proxy_chain_manager/gui_validate.py
Normal file
41
proxy_chain_manager/gui_validate.py
Normal 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)
|
||||||
|
)
|
||||||
313
proxy_chain_manager/leak_audit.py
Normal file
313
proxy_chain_manager/leak_audit.py
Normal 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))
|
||||||
64
proxy_chain_manager/leak_detect.py
Normal file
64
proxy_chain_manager/leak_detect.py
Normal 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"
|
||||||
158
proxy_chain_manager/mac_spoof.py
Normal file
158
proxy_chain_manager/mac_spoof.py
Normal 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
|
||||||
22
proxy_chain_manager/paths.py
Normal file
22
proxy_chain_manager/paths.py
Normal 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}"
|
||||||
106
proxy_chain_manager/pool_ops.py
Normal file
106
proxy_chain_manager/pool_ops.py
Normal 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))
|
||||||
291
proxy_chain_manager/privacy_lan.py
Normal file
291
proxy_chain_manager/privacy_lan.py
Normal 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
|
||||||
201
proxy_chain_manager/proxy_picker.py
Normal file
201
proxy_chain_manager/proxy_picker.py
Normal 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)
|
||||||
160
proxy_chain_manager/secrets_store.py
Normal file
160
proxy_chain_manager/secrets_store.py
Normal 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)
|
||||||
949
proxy_chain_manager/service.py
Normal file
949
proxy_chain_manager/service.py
Normal 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
|
||||||
153
proxy_chain_manager/signup_extension/content.js
Normal file
153
proxy_chain_manager/signup_extension/content.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
21
proxy_chain_manager/signup_extension/manifest.json
Normal file
21
proxy_chain_manager/signup_extension/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
444
proxy_chain_manager/signup_prep.py
Normal file
444
proxy_chain_manager/signup_prep.py
Normal 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")
|
||||||
574
proxy_chain_manager/sysproxy.py
Normal file
574
proxy_chain_manager/sysproxy.py
Normal 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
|
||||||
292
proxy_chain_manager/telemetry_kill.py
Normal file
292
proxy_chain_manager/telemetry_kill.py
Normal 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
160
proxy_chain_manager/tray.py
Normal 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()
|
||||||
277
proxy_chain_manager/validator.py
Normal file
277
proxy_chain_manager/validator.py
Normal 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"
|
||||||
233
proxy_chain_manager/vpn_detect.py
Normal file
233
proxy_chain_manager/vpn_detect.py
Normal 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
|
||||||
231
proxy_chain_manager/webrtc_check.py
Normal file
231
proxy_chain_manager/webrtc_check.py
Normal 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
|
||||||
107
proxy_chain_manager/win_compat.py
Normal file
107
proxy_chain_manager/win_compat.py
Normal 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
|
||||||
84
proxy_chain_manager/windows_task.py
Normal file
84
proxy_chain_manager/windows_task.py
Normal 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
|
||||||
BIN
proxy_chain_manager/world_map.png
Normal file
BIN
proxy_chain_manager/world_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
5
requirements.txt
Normal file
5
requirements.txt
Normal 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
11
run.py
Normal 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
3
run_mac.command
Executable 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
5
scripts/app_launcher.sh
Executable 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
38
scripts/find_tk_python.sh
Executable 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
26
scripts/run_proxy_god.sh
Executable 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Tests package
|
||||||
48
tests/test_ban_tester.py
Normal file
48
tests/test_ban_tester.py
Normal 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()
|
||||||
105
tests/test_config_round_trip.py
Normal file
105
tests/test_config_round_trip.py
Normal 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
26
tests/test_fail_closed.py
Normal 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()
|
||||||
42
tests/test_firewall_helpers.py
Normal file
42
tests/test_firewall_helpers.py
Normal 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
60
tests/test_gost_util.py
Normal 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
424
tests/test_proxy_god.py
Normal 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()
|
||||||
48
tests/test_secrets_store.py
Normal file
48
tests/test_secrets_store.py
Normal 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
60
winreg.py
Normal 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")
|
||||||
Reference in New Issue
Block a user