Compare commits
43 Commits
9439037cac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4521d9ebe8 | ||
|
|
f8348765c4 | ||
|
|
860e3318a0 | ||
|
|
6c775c12a0 | ||
|
|
08f9979cc6 | ||
|
|
08683ab328 | ||
|
|
b852fd264f | ||
|
|
ad56f75e8a | ||
|
|
04d486a335 | ||
|
|
255fdf3e8c | ||
|
|
311abb933a | ||
|
|
792b320257 | ||
|
|
2ba43c3ff9 | ||
|
|
28f00c0887 | ||
|
|
01c19349ee | ||
| ac6bde8a08 | |||
| a27dd4c44d | |||
| a77c9060b9 | |||
| 6b826da2f3 | |||
| 380fe5431a | |||
| e76ed317c1 | |||
| 9be21956e8 | |||
| b7b005d188 | |||
| e97addfe2b | |||
| 4c9295bf56 | |||
| 313c66aa9a | |||
| fae37cae3c | |||
| 89b19bf4ab | |||
| 740456dad6 | |||
| 43fcd8cfce | |||
| 631c33b4e5 | |||
| cb315a03ea | |||
| c1448312d8 | |||
|
|
23e4b0b019 | ||
|
|
26863fc2a5 | ||
|
|
8258ca0997 | ||
|
|
f9c548872b | ||
|
|
094e0577fb | ||
|
|
6561bdc37f | ||
|
|
7657a37855 | ||
|
|
8f012402a6 | ||
|
|
8bd8d4267f | ||
|
|
d4296763ee |
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# EditorConfig is awesome: https://editorconfig.org
|
||||||
|
<!-- stewardship-standard: editorconfig-v1 -->
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{md,markdown}]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
27
.gitattributes
vendored
Normal file
27
.gitattributes
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# stewardship-standard: gitattributes-v1
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.md text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.c text eol=lf
|
||||||
|
*.cpp text eol=lf
|
||||||
|
*.h text eol=lf
|
||||||
|
*.hpp text eol=lf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.bin binary
|
||||||
|
*.elf binary
|
||||||
|
*.uf2 binary
|
||||||
21
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
21
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Bug Report
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Describe the problem and expected behavior.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Repo version/commit:
|
||||||
|
- OS/toolchain/board/service:
|
||||||
|
- Relevant configuration with secrets removed:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Paste only sanitized logs. Remove credentials, tokens, personal data, captures, dumps, and target identifiers.
|
||||||
13
.gitea/ISSUE_TEMPLATE/docs_task.md
Normal file
13
.gitea/ISSUE_TEMPLATE/docs_task.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Documentation Task
|
||||||
|
|
||||||
|
## Page Or Section
|
||||||
|
|
||||||
|
Name the README/wiki section that needs work.
|
||||||
|
|
||||||
|
## Change Needed
|
||||||
|
|
||||||
|
Describe what should be clearer, corrected, or added.
|
||||||
|
|
||||||
|
## Source Of Truth
|
||||||
|
|
||||||
|
Link to code, hardware notes, upstream docs, release notes, or maintainer decision.
|
||||||
14
.gitea/ISSUE_TEMPLATE/release_checklist.md
Normal file
14
.gitea/ISSUE_TEMPLATE/release_checklist.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Release Checklist
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Describe what is being released and why.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- [ ] README and wiki are current.
|
||||||
|
- [ ] Changelog entry exists.
|
||||||
|
- [ ] License/provenance is clear.
|
||||||
|
- [ ] No secrets or private data are included.
|
||||||
|
- [ ] Firmware/binary artifacts include SHA256 hashes and target details.
|
||||||
|
- [ ] Build or smoke-check result is recorded.
|
||||||
7
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
7
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Pull Request Checklist
|
||||||
|
|
||||||
|
- [ ] Scope is clear and limited.
|
||||||
|
- [ ] README/wiki updates are included when behavior, setup, hardware, or release process changes.
|
||||||
|
- [ ] No secrets, tokens, private data, dumps, captures, or generated dependency folders are committed.
|
||||||
|
- [ ] Build/test/smoke-check result is documented.
|
||||||
|
- [ ] License or upstream provenance is preserved.
|
||||||
56
.github/workflows/release.yml
vendored
Normal file
56
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Release Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Optional version override (e.g. 1.2.0)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build Windows release
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Production release build
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$args = @()
|
||||||
|
if ("${{ github.event.inputs.version }}") { $args += "-Version", "${{ github.event.inputs.version }}" }
|
||||||
|
& ./scripts/release_build.ps1 @args
|
||||||
|
|
||||||
|
- name: Upload release artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ProxyGod-windows-amd64
|
||||||
|
path: |
|
||||||
|
releases/**/*
|
||||||
|
dist/ProxyChainManager.exe
|
||||||
|
dist/ProxyChainManager.exe.sha256
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Create GitHub Release (tag push only)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
releases/**/*.zip
|
||||||
|
releases/**/ProxyChainManager.exe
|
||||||
|
releases/**/ProxyChainManager.exe.sha256
|
||||||
|
releases/**/RELEASE_MANIFEST.json
|
||||||
|
releases/**/SBOM.json
|
||||||
|
generate_release_notes: true
|
||||||
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test Python ${{ matrix.python-version }}
|
||||||
|
runs-on: windows-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Syntax check (compileall)
|
||||||
|
run: python -m compileall -q proxy_chain_manager
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: python -m unittest discover -s tests -v
|
||||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -7,3 +7,30 @@ build/
|
|||||||
*.spec.bak
|
*.spec.bak
|
||||||
.env
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
|
# Operator credential files — never commit these
|
||||||
|
settings.json
|
||||||
|
settings.json.bak
|
||||||
|
settings.json.corrupt
|
||||||
|
signup_accounts.json
|
||||||
|
signup_draft.json
|
||||||
|
*.accounts.json
|
||||||
|
*.draft.json
|
||||||
|
|
||||||
|
# Screenshot / image noise
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
# Exceptions — keep bundled assets that are already tracked
|
||||||
|
!proxy_chain_manager/world_map.png
|
||||||
|
!docs/dashboard-screenshot.png
|
||||||
|
|
||||||
|
# Build-staged binaries — staged by scripts/prepare_bundled_gost.ps1
|
||||||
|
# (downloaded from go-gost release with verified SHA256), not source code
|
||||||
|
proxy_chain_manager/_bundled/
|
||||||
|
|
||||||
|
# Release output (scripts/release_build.ps1) — upload as CI artifacts / GitHub Release
|
||||||
|
releases/*
|
||||||
|
!releases/README.md
|
||||||
|
build/version_info.txt
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @drjones
|
||||||
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.
|
||||||
@@ -1,16 +1,49 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""PyInstaller spec for ProxyChainManager.
|
||||||
|
|
||||||
|
Build:
|
||||||
|
python -m PyInstaller --noconfirm --clean ProxyChainManager.spec
|
||||||
|
|
||||||
|
Always builds relative to the spec file's directory so the script works on
|
||||||
|
any machine / CI runner.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
|
# SPECPATH is provided by PyInstaller; fall back to CWD for IDE linters.
|
||||||
|
_ROOT = Path(globals().get("SPECPATH", os.getcwd())).resolve()
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
binaries = []
|
binaries = []
|
||||||
hiddenimports = ['pystray._win32']
|
hiddenimports = [
|
||||||
|
'pystray._win32',
|
||||||
|
'PIL._tkinter_finder',
|
||||||
|
]
|
||||||
|
|
||||||
tmp_ret = collect_all('customtkinter')
|
tmp_ret = collect_all('customtkinter')
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
|
|
||||||
|
# Bundle runtime data files. Each tuple is (src_relative_to_spec, dest_in_bundle).
|
||||||
|
_BUNDLE_DATA = [
|
||||||
|
('proxy_chain_manager/signup_extension', 'proxy_chain_manager/signup_extension'),
|
||||||
|
('proxy_chain_manager/world_map.png', 'proxy_chain_manager'),
|
||||||
|
# B-06: Bundle gost.exe (and its SHA256 sidecar) so first run works
|
||||||
|
# offline. scripts/prepare_bundled_gost.ps1 stages these files before
|
||||||
|
# PyInstaller runs. Missing files are silently skipped so source
|
||||||
|
# checkouts that haven't staged the binary still build.
|
||||||
|
('proxy_chain_manager/_bundled/gost.exe', 'proxy_chain_manager/_bundled'),
|
||||||
|
('proxy_chain_manager/_bundled/gost.exe.sha256','proxy_chain_manager/_bundled'),
|
||||||
|
]
|
||||||
|
for src, dest in _BUNDLE_DATA:
|
||||||
|
p = _ROOT / src
|
||||||
|
if p.exists():
|
||||||
|
datas.append((str(p), dest))
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['run.py'],
|
[str(_ROOT / 'run.py')],
|
||||||
pathex=[],
|
pathex=[str(_ROOT)],
|
||||||
binaries=binaries,
|
binaries=binaries,
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=hiddenimports,
|
hiddenimports=hiddenimports,
|
||||||
@@ -23,17 +56,14 @@ a = Analysis(
|
|||||||
)
|
)
|
||||||
pyz = PYZ(a.pure)
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
exe = EXE(
|
# Windows VERSIONINFO — generated by scripts/generate_version_info.py before build.
|
||||||
pyz,
|
_version_file = _ROOT / 'build' / 'version_info.txt'
|
||||||
a.scripts,
|
_exe_kwargs = dict(
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='ProxyChainManager',
|
name='ProxyChainManager',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=False,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False,
|
||||||
@@ -44,3 +74,14 @@ exe = EXE(
|
|||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
uac_admin=True,
|
uac_admin=True,
|
||||||
)
|
)
|
||||||
|
if _version_file.is_file():
|
||||||
|
_exe_kwargs['version'] = str(_version_file)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
**_exe_kwargs,
|
||||||
|
)
|
||||||
|
|||||||
238
README.md
238
README.md
@@ -1,88 +1,138 @@
|
|||||||
# Proxy God
|
# Proxy God
|
||||||
|
|
||||||
**Multi-hop proxy chains. Self-healing. Windows-native. You pick the exit; the machine enforces the rules.**
|

|
||||||
|
|
||||||
Not another toy script. This is a full **rotating chain engine** on top of **GOST**: it pulls lists, stress-tests proxies in parallel, builds random hops, and **keeps checking** until something dies—then it **rotates** and keeps going. Optional **firewall kill-switch** so traffic either goes through your chain or **it doesn’t go out at all** (when you run as Admin). Works **under NordVPN** (or any VPN) so your outer tunnel stays first—then the chaos of public proxies happens **inside** that envelope.
|
**Mission-grade rotating proxy chains for Windows.**
|
||||||
|
**Self-healing. Leak-aware. Aggressive by default.**
|
||||||
|
|
||||||
|
This is not a toy wrapper around random free lists.
|
||||||
|
Proxy God runs a full chain engine on top of **GOST**: fetch, dedupe, parallel-validate, build hops, verify exit, monitor health, rotate on failure, repeat forever.
|
||||||
|
|
||||||
|
When run as Admin, it can enforce a hard rule:
|
||||||
|
**traffic goes through the chain, or traffic does not go out.**
|
||||||
|
|
||||||
```
|
```
|
||||||
YOU → VPN (outer tunnel) → Hop 1 → Hop 2 → … → Last hop → Internet
|
YOU -> VPN (outer tunnel) -> Hop 1 -> Hop 2 -> ... -> Exit hop -> Internet
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does (the short version)
|
## Why it exists
|
||||||
|
|
||||||
| Layer | What happens |
|
- Public proxies die constantly. This keeps rotating until it finds alive paths.
|
||||||
|--------|----------------|
|
- "Connected" means nothing without proof. This continuously re-validates exit behavior.
|
||||||
| **Pool** | Fetches live lists (Proxifly CDN), dedupes, validates like a hammer. |
|
- Browser traffic leaks in weird ways on Windows. This now enforces proxy policy and disables QUIC where needed.
|
||||||
| **Chain** | Random **N-hop** path through survivors—no lazy repeats in a cycle. |
|
- If the chain breaks, it should fail closed (optional kill-switch), not fail open.
|
||||||
| **Exit check** | If your “exit IP” looks like your **real** IP, the chain is **dead**—rotate. |
|
|
||||||
| **System proxy** | WinINet gets the rules—apps that respect Windows proxy **follow the chain**. |
|
|
||||||
| **Kill-switch** | Optional **netsh** lockdown: **GOST**, **this app**, **NordVPN stack** get out; everything else can **wait** (Admin). |
|
|
||||||
| **Tray** | Green / yellow / red. You know the state without opening the window. |
|
|
||||||
| **Boot** | Scheduled task—**fire on logon**, keep the machine in the game. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Firepower (features)
|
## Core capabilities
|
||||||
|
|
||||||
- **Auto pool** — Pull, validate, shuffle, drain. Rinse on a timer.
|
| Layer | Behavior |
|
||||||
- **1–8 hops** — Slider + top-bar **+/−**. You control depth.
|
|------|------|
|
||||||
- **Obfuscation modes** — Auto, HTTP-only, SOCKS5-only, Random Mix.
|
| **Pool engine** | Pulls proxy feeds, dedupes, validates in parallel, keeps survivors only. |
|
||||||
- **Fixed exit** — **Last hop only**: yours. Everything else still rotates. Hop count **1** = **only** your exit, no pool.
|
| **Chain engine** | Builds random N-hop paths, avoids lazy repeats during a cycle. |
|
||||||
- **Pinned chain** — Full manual order when you want to **own** the path.
|
| **Pinned mode** | Full manual chain order when you want deterministic routing. |
|
||||||
- **Leak sniffing** — Compares exit IP vs direct; mismatch or no exit = **rotate**.
|
| **Exit control** | Test and save a fixed exit/final hop from GUI (`host:port:user:pass` supported). |
|
||||||
- **GOST** — Downloaded once; **Defender exclusion** on the app folder so AV doesn’t eat the binary.
|
| **Leak checks** | Compares chain exit against direct/VPN identity and rotates on leak signatures. |
|
||||||
- **VM-safe** — Listener stays on **127.0.0.1**—clone the box, DHCP can change; **this doesn’t care**.
|
| **HTTPS tunnel probe** | Detects HTTP-only chains that would break real browsers (CONNECT failures). |
|
||||||
|
| **System proxy enforcement** | WinINet + Connections blob + WinHTTP + browser policy integration. |
|
||||||
|
| **Kill-switch** | `netsh` firewall lockdown (Admin): allow only approved outbound paths. |
|
||||||
|
| **Tray telemetry** | Red/yellow/green status with live exit visibility. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature set (high level)
|
||||||
|
|
||||||
|
- Auto pool refresh and shuffle-drain rotation model
|
||||||
|
- 1-8 hop chains
|
||||||
|
- Obfuscation modes: Auto, HTTP-only, SOCKS5-only, Random Mix
|
||||||
|
- Fixed-exit and full manual-chain workflows
|
||||||
|
- Fast per-hop GUI testing with live status colors
|
||||||
|
- VPN-aware leak logic
|
||||||
|
- LAN privacy lockdown (LLMNR / NetBIOS / mDNS controls)
|
||||||
|
- Telemetry hardening and forensic wipe tooling
|
||||||
|
- Hardened/spoofed Firefox launch profiles with persona and cookie modes
|
||||||
|
- VM-safe loopback listener (`127.0.0.1`) for clone portability
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Windows 10/11 x64**
|
- **Windows 10/11 x64**
|
||||||
- **Python 3.10+** on PATH (or the **`py`** launcher with a 3.10+ install) — *only needed to build*; the shipped **`.exe`** runs without Python
|
- **Python 3.10+** on PATH (or `py` launcher) to build from source
|
||||||
- **VPN** recommended (Nord or any)—outer tunnel before the proxy zoo
|
- Python is **not required** to run the built `.exe`
|
||||||
|
- VPN strongly recommended (outer tunnel first)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Clone / pull → one build script → Desktop
|
## Build (Windows)
|
||||||
|
|
||||||
Typical flow:
|
Requires **Python 3.10+** and **Windows 10/11 x64**. Python is not needed to *run* the built `.exe`.
|
||||||
|
|
||||||
1. **Clone** (or `git pull` if you already have the repo):
|
|
||||||
```cmd
|
|
||||||
git clone https://gitea.thetempleofdoom.com/drjones/proxy-god.git
|
|
||||||
cd proxy-god
|
|
||||||
```
|
|
||||||
2. **Python** (first machine only): if `python --version` / `py -3` isn’t 3.10+, install then reopen the terminal:
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements
|
winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements
|
||||||
```
|
```
|
||||||
3. **Build** — upgrades **pip**, installs **requirements.txt** + **PyInstaller**, produces **`dist\ProxyChainManager.exe`**, copies **`ProxyChainManager.exe`** to your **Desktop**, and creates **`Proxy God.lnk`** pointing at it:
|
|
||||||
```powershell
|
### Production release (recommended for shipping)
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup_and_build.ps1
|
|
||||||
|
Full pipeline: tests → bundled GOST → PyInstaller → SHA256 + SBOM + manifest → zip.
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
build_release.bat
|
||||||
```
|
```
|
||||||
Same steps from **`build_exe.bat`** or double‑click **`FirstRun_Build_And_Install.bat`** (checks Python 3.10+, then runs the same script; pauses at the end so you can read errors).
|
|
||||||
|
|
||||||
| Where | What |
|
Or:
|
||||||
|--------|------|
|
|
||||||
| `dist\ProxyChainManager.exe` | Built app (same bits as Desktop copy) |
|
|
||||||
| **Desktop → `Proxy God.lnk`** | **Double‑click this** — shortcut to the Desktop exe |
|
|
||||||
| **Desktop → `ProxyChainManager.exe`** | Same app (refreshed every build) |
|
|
||||||
|
|
||||||
After a successful build, if the shortcut is missing but the exe exists, you can recreate it:
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\release_build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `releases\ProxyGod-v{version}-windows-amd64.zip` and `releases\v{version}\` (exe, checksum, SBOM, manifest). See [docs/RELEASE_PROCESS.md](docs/RELEASE_PROCESS.md).
|
||||||
|
|
||||||
|
### Download (no build required)
|
||||||
|
|
||||||
|
**Latest release: [v1.0.0](https://github.com/sudo-jones-cmd/PROXY_GOD/releases/tag/v1.0.0)**
|
||||||
|
|
||||||
|
- `ProxyGod-v1.0.0-windows-amd64.zip` — full package (exe + checksum + SBOM + runbook)
|
||||||
|
- Or grab `ProxyChainManager.exe` from the release assets
|
||||||
|
|
||||||
|
Local copy after building: [releases/v1.0.0/](releases/v1.0.0/)
|
||||||
|
|
||||||
|
Tag-driven CI build (GitHub):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
|
git push github v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Developer build (Desktop copy)
|
||||||
|
|
||||||
|
Faster iteration — copies `dist\ProxyChainManager.exe` to Desktop + shortcut.
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
build_exe.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Or `FirstRun_Build_And_Install.bat` (same script, pauses on error).
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|------|
|
||||||
|
| `dist\ProxyChainManager.exe` | Built binary |
|
||||||
|
| `releases\` | Production release folders + zip (from `release_build.ps1`) |
|
||||||
|
| `Desktop\ProxyChainManager.exe` | Dev build desktop copy |
|
||||||
|
| `Desktop\Proxy God.lnk` | Shortcut |
|
||||||
|
|
||||||
|
Recreate shortcut only:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\create_desktop_shortcut.ps1
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\create_desktop_shortcut.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
First run of the **exe**: accept **UAC** if you want the firewall kill-switch. In the app, press **▶ Start**.
|
|
||||||
|
|
||||||
You do **not** need Python installed to **run** the Desktop **`.exe`** — only to **build** it with the script above.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start (run from source, no exe)
|
## Run from source (no exe)
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cd proxy-god
|
cd proxy-god
|
||||||
@@ -90,80 +140,96 @@ pip install -r requirements.txt
|
|||||||
python run.py
|
python run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Administrator** = full kill-switch. No admin = still runs; firewall enforcement steps aside.
|
- Run as **Administrator** for full firewall and policy features.
|
||||||
|
- Non-admin mode still works, but privileged controls are skipped.
|
||||||
|
|
||||||
### Self-test (core logic, no GUI)
|
---
|
||||||
|
|
||||||
|
## Validation / tests
|
||||||
|
|
||||||
|
Core logic test suite:
|
||||||
```cmd
|
```cmd
|
||||||
python -m unittest discover -s tests -v
|
python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Hits config sanitization, fetcher smoke, validator timeouts, and a live `get_direct_ip` check (skips if you’re offline).
|
Covers config sanitization, parser behavior, validator timing behavior, leak checks, and smoke paths.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run it like you mean it
|
## Operator quickstart
|
||||||
|
|
||||||
1. Launch **`Proxy God.lnk`** on the Desktop (or **`ProxyChainManager.exe`**) — UAC if you want the kill-switch.
|
1. Launch `Proxy God.lnk` (or `ProxyChainManager.exe`).
|
||||||
2. Tweak **Settings** if you’re picky—defaults are **solid** out of the box.
|
2. Accept UAC if you want full enforcement.
|
||||||
3. Hit **Start**. First run grabs **GOST**, whitelists the folder in Defender, pulls lists, validates, chains, verifies exit IP, sets **system proxy**, engages **firewall** (if Admin).
|
3. Configure Chain Builder if you need manual control.
|
||||||
4. Close the window → **tray**; the engine **keeps running** until you **Quit**.
|
4. Press **Start**.
|
||||||
|
5. Minimize to tray; engine keeps running until quit.
|
||||||
|
|
||||||
|
First run bootstraps GOST, fetches/validates pool, builds chain, verifies exit, applies proxy stack, then enforces configured protections.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chain Builder (where you get surgical)
|
## Chain Builder notes
|
||||||
|
|
||||||
- **Mode** — Protocol mix for the pool.
|
- You can paste exit proxy formats like:
|
||||||
- **Hops** — Total length; **fixed exit** always rides **last** (unless you pinned a full manual chain).
|
- `host:port:user:pass`
|
||||||
- **Manual list** — Add, reorder, **pin** when you don’t trust randomness.
|
- `user:pass@host:port`
|
||||||
- **Sources** — Your JSON URLs; defaults are Proxifly CDN endpoints.
|
- `scheme://host:port`
|
||||||
|
- **Test exit** validates the candidate.
|
||||||
|
- **Save as final hop** appends that exit to your manual chain as the last hop.
|
||||||
|
- "Use this chain on Start" makes manual routing authoritative.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Settings cheat sheet
|
## Settings cheat sheet
|
||||||
|
|
||||||
| Setting | Default | Meaning |
|
| Setting | Default | Meaning |
|
||||||
|--------|---------|--------|
|
|------|------|------|
|
||||||
| Local port | `18888` | Where your HTTP proxy **listens** |
|
| Local port | `18888` | Local HTTP listener |
|
||||||
| Health check | `180` s | How often the exit **gets re-proven** |
|
| Health check | `180` s | Exit re-validation interval |
|
||||||
| Pool refresh | `1800` s | How often lists get **re-fetched** and re-tested |
|
| Pool refresh | `1800` s | Re-fetch + re-validate cadence |
|
||||||
| Concurrency | `64` | How hard you **spam** validation |
|
| Concurrency | `64` | Validator fan-out |
|
||||||
| Stop after N valid | `20` | **Early-exit** — stop testing as soon as N good proxies found |
|
| Stop after N valid | `20` | Early-exit threshold |
|
||||||
| Max candidates | `200` | Cap per cycle (shuffled, so random sample) |
|
| Max candidates | `200` | Random sample cap per cycle |
|
||||||
| Timeout | `12` s | Per-proxy **patience** |
|
| Timeout | `12` s | Per-proxy timeout |
|
||||||
| Kill-switch | ON | **All-or-nothing** outbound (when Admin + firewall engaged) |
|
| Kill-switch | ON | Fail-closed outbound policy (Admin) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack (for people who read code)
|
## Code layout
|
||||||
|
|
||||||
```
|
```text
|
||||||
app.py Dark UI — CustomTkinter, tabs, tray
|
app.py UI (CustomTkinter), tray wiring, operator workflows
|
||||||
service.py Async loop — pool, GOST, health, rotation
|
service.py async chain loop, rotation, health, enforcement
|
||||||
gost_util.py GOST binary + Defender hook
|
gost_util.py GOST bootstrap and process management
|
||||||
firewall.py netsh kill-switch
|
firewall.py netsh kill-switch
|
||||||
sysproxy.py Registry + WinINet broadcast
|
sysproxy.py WinINet/Connections/WinHTTP/browser policy handling
|
||||||
tray.py Status icon
|
validator.py parallel checks + exit/HTTPS probes
|
||||||
validator.py httpx + SOCKS — parallel checks
|
fetcher.py source ingestion
|
||||||
fetcher.py Proxifly JSON
|
browser_*.py hardened/spoofed browser profile + launcher
|
||||||
config.py Settings + sanitization
|
config.py settings model and sanitization
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Default sources
|
## Default sources
|
||||||
|
|
||||||
[Proxifly / free-proxy-list](https://github.com/proxifly/free-proxy-list) over jsDelivr—**HTTP**, **SOCKS5**, **HTTPS** JSON feeds. Roll your own: any URL that returns `[{"proxy":"http://ip:port",...}, ...]`.
|
Default feeds are from [Proxifly / free-proxy-list](https://github.com/proxifly/free-proxy-list) via jsDelivr (`HTTP`, `SOCKS5`, `HTTPS` JSON).
|
||||||
|
|
||||||
|
Custom source format expected:
|
||||||
|
```json
|
||||||
|
[{"proxy":"http://ip:port"}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Reality check (read this)
|
## Reality check
|
||||||
|
|
||||||
- **Public proxies are hostile**—treat them as **disposable**. No banking over cleartext.
|
- Public proxies are hostile and disposable.
|
||||||
- **VPN first** means proxy operators see the **VPN exit**, not your home ISP.
|
- VPN-first masks home ISP from proxy operators.
|
||||||
- **Kill-switch** is **serious**—it can block apps that don’t play by proxy rules. Know what you’re doing.
|
- Kill-switch is intentionally strict and can block non-compliant apps.
|
||||||
- **Clone-friendly**: bind is **loopback**—this repo doesn’t bake in LAN IPs.
|
- If HTTPS tunnel probe fails, browsers will fail even if HTTP checks look green.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Proxy God** — *not because it’s polite. Because it runs.*
|
**Proxy God**
|
||||||
|
**Built to keep moving when proxies die. Built to fail closed when trust dies.**
|
||||||
|
|||||||
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.
|
||||||
27
build_release.bat
Normal file
27
build_release.bat
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
title Proxy God — production release build
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === Proxy God: production release (tests + SBOM + zip) ===
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where py >nul 2>nul && py -3 -c "import sys; assert sys.version_info>=(3,10)" 2>nul && goto RUN
|
||||||
|
where python >nul 2>nul && python -c "import sys; assert sys.version_info>=(3,10)" 2>nul && goto RUN
|
||||||
|
|
||||||
|
echo [X] Python 3.10+ not found on PATH.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:RUN
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\release_build.ps1" %*
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Release build FAILED.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
endlocal
|
||||||
2
dev-requirements.txt
Normal file
2
dev-requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Build / development dependencies — pinned for reproducible builds.
|
||||||
|
pyinstaller==6.10.0
|
||||||
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 |
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_")
|
||||||
371
proxy_chain_manager/browser_launcher.py
Normal file
371
proxy_chain_manager/browser_launcher.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
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 = (
|
||||||
|
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."""
|
||||||
|
# 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)."""
|
||||||
|
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
|
||||||
|
# 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:
|
||||||
|
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)
|
||||||
@@ -1,12 +1,41 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote, unquote, urlparse, urlunparse
|
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||||
|
|
||||||
from .paths import app_data_dir
|
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:
|
def _host_port_for_url(host: str, port: int | None) -> str:
|
||||||
"""``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals)."""
|
"""``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals)."""
|
||||||
@@ -78,6 +107,57 @@ def merge_proxy_credentials(raw_url: str, username: str = "", password: str = ""
|
|||||||
return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/")
|
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:
|
def redact_proxy_url(url: str) -> str:
|
||||||
"""Same URL shape for logs/UI, with credentials replaced by ``***``."""
|
"""Same URL shape for logs/UI, with credentials replaced by ``***``."""
|
||||||
t = (url or "").strip()
|
t = (url or "").strip()
|
||||||
@@ -108,6 +188,9 @@ OBFUSCATION_LABELS = {
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
|
# Schema version — used by migrate() to upgrade old settings.json files.
|
||||||
|
settings_version: int = SETTINGS_SCHEMA_VERSION
|
||||||
|
|
||||||
# ── network ──────────────────────────────────────────────────────────
|
# ── network ──────────────────────────────────────────────────────────
|
||||||
local_host: str = LISTEN_HOST
|
local_host: str = LISTEN_HOST
|
||||||
local_port: int = 18888
|
local_port: int = 18888
|
||||||
@@ -115,8 +198,10 @@ class Settings:
|
|||||||
# ── chain ─────────────────────────────────────────────────────────────
|
# ── chain ─────────────────────────────────────────────────────────────
|
||||||
chain_length: int = 3 # number of hops (2-8)
|
chain_length: int = 3 # number of hops (2-8)
|
||||||
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
||||||
use_pinned_chain: bool = False # use manually ordered chain
|
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
||||||
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
|
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)
|
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
|
||||||
manual_exit_proxy: str = ""
|
manual_exit_proxy: str = ""
|
||||||
|
|
||||||
@@ -135,6 +220,34 @@ class Settings:
|
|||||||
kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running
|
kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running
|
||||||
proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>"
|
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 ───────────────────────────────────────────────────────────
|
||||||
sources: list[str] = field(
|
sources: list[str] = field(
|
||||||
default_factory=lambda: [
|
default_factory=lambda: [
|
||||||
@@ -152,6 +265,40 @@ class Settings:
|
|||||||
return app_data_dir() / "settings.json"
|
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]:
|
def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
|
||||||
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
|
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
|
||||||
changed = False
|
changed = False
|
||||||
@@ -236,6 +383,16 @@ def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
|
|||||||
if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources) or len(s.sources) == 0:
|
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)
|
s.sources = list(Settings().sources)
|
||||||
changed = True
|
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
|
return s, changed
|
||||||
|
|
||||||
|
|
||||||
@@ -253,8 +410,15 @@ def load_settings() -> Settings:
|
|||||||
return Settings()
|
return Settings()
|
||||||
try:
|
try:
|
||||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
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()
|
return Settings()
|
||||||
|
raw = migrate(raw)
|
||||||
s = Settings()
|
s = Settings()
|
||||||
for k, v in raw.items():
|
for k, v in raw.items():
|
||||||
if hasattr(s, k):
|
if hasattr(s, k):
|
||||||
@@ -286,4 +450,10 @@ def save_settings(s: Settings) -> None:
|
|||||||
s, _ = sanitize_settings(s)
|
s, _ = sanitize_settings(s)
|
||||||
s, _ = normalize_listen_host(s)
|
s, _ = normalize_listen_host(s)
|
||||||
p = app_data_dir() / "settings.json"
|
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")
|
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
|
||||||
|
|||||||
314
proxy_chain_manager/dns_leak.py
Normal file
314
proxy_chain_manager/dns_leak.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""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
|
||||||
|
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)."""
|
||||||
|
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]:
|
||||||
|
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")
|
||||||
406
proxy_chain_manager/fingerprint.py
Normal file
406
proxy_chain_manager/fingerprint.py
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
"""Device fingerprint audit and OS-level hardening helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
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 ""
|
||||||
|
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:
|
||||||
|
import platform
|
||||||
|
v = platform.version()
|
||||||
|
r = platform.release()
|
||||||
|
m = platform.machine()
|
||||||
|
return f"Windows {r} build {v} {m}"
|
||||||
|
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:
|
||||||
|
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],
|
||||||
|
)
|
||||||
@@ -13,24 +13,18 @@ When disengaged:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import glob
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import gost_exe_path
|
from .paths import gost_exe_path
|
||||||
|
from .vpn_detect import expand_vpn_executables
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
RULE_PREFIX = "PCM_"
|
RULE_PREFIX = "PCM_"
|
||||||
|
|
||||||
NORD_GLOBS = [
|
|
||||||
r"C:\Program Files\NordVPN\*.exe",
|
|
||||||
r"C:\Program Files\NordUpdater\*.exe",
|
|
||||||
r"C:\Program Files\NordVPN\NordSec ThreatProtection\*.exe",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def is_admin() -> bool:
|
def is_admin() -> bool:
|
||||||
try:
|
try:
|
||||||
@@ -99,13 +93,6 @@ def _resolve_self_exe() -> Path:
|
|||||||
return Path(sys.executable).resolve()
|
return Path(sys.executable).resolve()
|
||||||
|
|
||||||
|
|
||||||
def _expand_nord_exes() -> list[str]:
|
|
||||||
out: list[str] = []
|
|
||||||
for pattern in NORD_GLOBS:
|
|
||||||
out.extend(glob.glob(pattern))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def engage(gost_path: Path | None = None) -> tuple[bool, str]:
|
def engage(gost_path: Path | None = None) -> tuple[bool, str]:
|
||||||
"""Activate kill-switch firewall. Returns (success, message)."""
|
"""Activate kill-switch firewall. Returns (success, message)."""
|
||||||
if not is_admin():
|
if not is_admin():
|
||||||
@@ -113,7 +100,7 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
|
|||||||
|
|
||||||
gost = gost_path or gost_exe_path()
|
gost = gost_path or gost_exe_path()
|
||||||
self_exe = _resolve_self_exe()
|
self_exe = _resolve_self_exe()
|
||||||
nord_exes = _expand_nord_exes()
|
vpn_exes = expand_vpn_executables()
|
||||||
|
|
||||||
_delete_rules()
|
_delete_rules()
|
||||||
|
|
||||||
@@ -137,10 +124,10 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
|
|||||||
_add_rule(f"Python_{sibling}", dir="out", action="allow",
|
_add_rule(f"Python_{sibling}", dir="out", action="allow",
|
||||||
program=f'"{p}"', protocol="any")
|
program=f'"{p}"', protocol="any")
|
||||||
|
|
||||||
# Allow all NordVPN executables
|
# Allow VPN client executables (Nord, WireGuard, OpenVPN, etc.)
|
||||||
for i, npath in enumerate(nord_exes):
|
for i, vpath in enumerate(vpn_exes):
|
||||||
_add_rule(f"Nord_{i}", dir="out", action="allow",
|
_add_rule(f"VPN_{i}", dir="out", action="allow",
|
||||||
program=f'"{npath}"', protocol="any")
|
program=f'"{vpath}"', protocol="any")
|
||||||
|
|
||||||
# Allow DHCP (or you lose your adapter)
|
# Allow DHCP (or you lose your adapter)
|
||||||
_add_rule("DHCP", dir="out", action="allow",
|
_add_rule("DHCP", dir="out", action="allow",
|
||||||
@@ -155,8 +142,8 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
|
|||||||
# Set default outbound to BLOCK
|
# Set default outbound to BLOCK
|
||||||
_set_outbound_policy("blockinbound,blockoutbound")
|
_set_outbound_policy("blockinbound,blockoutbound")
|
||||||
|
|
||||||
log.info("Firewall kill-switch engaged. %d Nord exes whitelisted.", len(nord_exes))
|
log.info("Firewall kill-switch engaged. %d VPN exes whitelisted.", len(vpn_exes))
|
||||||
return True, f"Kill-switch ON. {len(nord_exes)} Nord processes whitelisted."
|
return True, f"Kill-switch ON. {len(vpn_exes)} VPN client(s) whitelisted."
|
||||||
|
|
||||||
|
|
||||||
def disengage() -> tuple[bool, str]:
|
def disengage() -> tuple[bool, str]:
|
||||||
@@ -173,3 +160,23 @@ def is_engaged() -> bool:
|
|||||||
"""Quick check: are our rules present?"""
|
"""Quick check: are our rules present?"""
|
||||||
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_PREFIX}GOST", "dir=out"])
|
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_PREFIX}GOST", "dir=out"])
|
||||||
return RULE_PREFIX in (r.stdout or "")
|
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)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -19,6 +21,16 @@ GOST_RELEASE_ZIP = (
|
|||||||
"gost_3.2.6_windows_amd64.zip"
|
"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.exe.sha256"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _add_defender_exclusion(path: Path) -> None:
|
def _add_defender_exclusion(path: Path) -> None:
|
||||||
"""Add Windows Defender exclusion so GOST is not quarantined."""
|
"""Add Windows Defender exclusion so GOST is not quarantined."""
|
||||||
@@ -35,14 +47,119 @@ def _add_defender_exclusion(path: Path) -> None:
|
|||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
)
|
)
|
||||||
log.info("Defender exclusion added for %s", path.parent)
|
log.info("Defender exclusion added for %s", path.parent)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass # non-fatal
|
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)
|
||||||
|
bundle_exe = Path(base) / "proxy_chain_manager" / "_bundled" / "gost.exe"
|
||||||
|
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:
|
def ensure_gost(target: Path | None = None) -> Path:
|
||||||
exe = target or gost_exe_path()
|
exe = target or gost_exe_path()
|
||||||
if exe.is_file() and exe.stat().st_size > 10_000:
|
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
|
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)
|
exe.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
|
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
|
||||||
_add_defender_exclusion(exe)
|
_add_defender_exclusion(exe)
|
||||||
@@ -53,6 +170,8 @@ def ensure_gost(target: Path | None = None) -> Path:
|
|||||||
data = r.content
|
data = r.content
|
||||||
if len(data) < 64 or data[:2] != b"PK":
|
if len(data) < 64 or data[:2] != b"PK":
|
||||||
raise RuntimeError("Downloaded GOST zip looks invalid (not a zip).")
|
raise RuntimeError("Downloaded GOST zip looks invalid (not a zip).")
|
||||||
|
# Integrity check before extraction
|
||||||
|
_verify_zip_sha256(data, GOST_RELEASE_ZIP_SHA256)
|
||||||
with zipfile.ZipFile(io.BytesIO(data), "r") as z:
|
with zipfile.ZipFile(io.BytesIO(data), "r") as z:
|
||||||
names = [n for n in z.namelist() if n.lower().endswith("gost.exe")]
|
names = [n for n in z.namelist() if n.lower().endswith("gost.exe")]
|
||||||
if not names:
|
if not names:
|
||||||
@@ -61,6 +180,8 @@ def ensure_gost(target: Path | None = None) -> Path:
|
|||||||
shutil.copyfileobj(src, dst)
|
shutil.copyfileobj(src, dst)
|
||||||
# Add exclusion again after write in case Defender scanned during extraction
|
# Add exclusion again after write in case Defender scanned during extraction
|
||||||
_add_defender_exclusion(exe)
|
_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)
|
log.info("GOST installed at %s", exe)
|
||||||
return exe
|
return exe
|
||||||
|
|
||||||
@@ -122,8 +243,9 @@ def terminate_process(proc: subprocess.Popen | None) -> None:
|
|||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
log.debug("GOST terminate failed (%s) — escalating to kill.", exc)
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
except Exception as kill_exc:
|
||||||
pass
|
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
|
||||||
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)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import signal
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -16,11 +18,45 @@ from .config import (
|
|||||||
redact_proxy_url,
|
redact_proxy_url,
|
||||||
save_settings,
|
save_settings,
|
||||||
)
|
)
|
||||||
|
from .dns_leak import flush_dns_cache
|
||||||
from .fetcher import fetch_proxy_json, normalize_entries
|
from .fetcher import fetch_proxy_json, normalize_entries
|
||||||
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
|
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 .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
|
||||||
from .sysproxy import clear_system_proxy, set_system_proxy
|
from .leak_detect import is_chain_leak, leak_reason
|
||||||
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,29 +66,23 @@ Notify = Callable[[dict[str, Any]], None]
|
|||||||
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
|
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
|
||||||
|
|
||||||
|
|
||||||
def _is_same_network(ip_a: str | None, ip_b: str | None, prefix_len: int = 16) -> bool:
|
# Back-compat for tests
|
||||||
"""True if two IPv4 addresses share the same /<prefix_len> subnet.
|
from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
|
||||||
|
|
||||||
With NordVPN (or any VPN), the VPN provider rotates IPs so an exact-match
|
|
||||||
comparison misses leaks where the chain exits through the VPN tunnel directly.
|
async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool:
|
||||||
A /16 check catches same-ISP/same-VPN exit while still allowing genuine
|
"""Open and close a TCP socket; True if the handshake completes within *timeout*."""
|
||||||
unrelated proxies that happen to share a /24 with the VPN exit.
|
|
||||||
Returns False if either address is None or non-IPv4.
|
|
||||||
"""
|
|
||||||
if not ip_a or not ip_b:
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
a_parts = [int(x) for x in ip_a.split(".")]
|
reader, writer = await asyncio.wait_for(
|
||||||
b_parts = [int(x) for x in ip_b.split(".")]
|
asyncio.open_connection(host, port), timeout=timeout,
|
||||||
if len(a_parts) != 4 or len(b_parts) != 4:
|
)
|
||||||
return False
|
writer.close()
|
||||||
|
try:
|
||||||
def to_int(parts: list[int]) -> int:
|
await writer.wait_closed()
|
||||||
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
|
return True
|
||||||
return (to_int(a_parts) & mask) == (to_int(b_parts) & mask)
|
except Exception: # noqa: BLE001
|
||||||
except Exception:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -65,8 +95,12 @@ class ChainService:
|
|||||||
self._force_rotate = threading.Event()
|
self._force_rotate = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._settings_lock = threading.Lock()
|
||||||
self._settings = load_settings()
|
self._settings = load_settings()
|
||||||
self._current_chain: list[str] = []
|
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
|
# Shuffle-and-drain pool state — never repeat a proxy within a cycle
|
||||||
self._available: list[str] = [] # proxies not yet used this cycle
|
self._available: list[str] = [] # proxies not yet used this cycle
|
||||||
@@ -74,12 +108,38 @@ class ChainService:
|
|||||||
# Per-session blacklist: proxies that crashed GOST immediately
|
# Per-session blacklist: proxies that crashed GOST immediately
|
||||||
self._blacklist: set[str] = set()
|
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:
|
def _manual_exit_url(self) -> str | None:
|
||||||
|
with self._settings_lock:
|
||||||
u = normalize_proxy_url(self._settings.manual_exit_proxy)
|
u = normalize_proxy_url(self._settings.manual_exit_proxy)
|
||||||
return u if u else None
|
return u if u else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settings(self) -> Settings:
|
def settings(self) -> Settings:
|
||||||
|
with self._settings_lock:
|
||||||
return self._settings
|
return self._settings
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -87,12 +147,24 @@ class ChainService:
|
|||||||
return list(self._current_chain)
|
return list(self._current_chain)
|
||||||
|
|
||||||
def update_settings(self, s: Settings) -> None:
|
def update_settings(self, s: Settings) -> None:
|
||||||
|
with self._settings_lock:
|
||||||
self._settings = s
|
self._settings = s
|
||||||
save_settings(s)
|
save_settings(s)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._thread and self._thread.is_alive():
|
if self._thread and self._thread.is_alive():
|
||||||
return
|
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._stop.clear()
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._run_thread, name="ChainService", daemon=True
|
target=self._run_thread, name="ChainService", daemon=True
|
||||||
@@ -105,22 +177,118 @@ class ChainService:
|
|||||||
self._proc = None
|
self._proc = None
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=15)
|
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._teardown_network()
|
||||||
self._notify({"type": "state", "running": False})
|
self._notify({"type": "state", "running": False})
|
||||||
|
|
||||||
def rotate_now(self) -> None:
|
def rotate_now(self) -> None:
|
||||||
self._force_rotate.set()
|
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:
|
def _teardown_network(self) -> None:
|
||||||
clear_system_proxy()
|
clear_system_proxy()
|
||||||
self._notify({"type": "log", "text": "System proxy cleared."})
|
self._notify({"type": "log", "text": "System proxy cleared."})
|
||||||
if is_admin() and self._settings.kill_switch_enabled:
|
self._restore_privacy()
|
||||||
|
with self._settings_lock:
|
||||||
|
ks_enabled = self._settings.kill_switch_enabled
|
||||||
|
if is_admin() and ks_enabled:
|
||||||
ok, msg = fw_disengage()
|
ok, msg = fw_disengage()
|
||||||
self._notify({"type": "log", "text": msg})
|
self._notify({"type": "log", "text": msg})
|
||||||
self._notify({"type": "firewall", "engaged": False})
|
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:
|
def _run_thread(self) -> None:
|
||||||
try:
|
try:
|
||||||
asyncio.run(self._async_main())
|
asyncio.run(self._async_main())
|
||||||
@@ -165,13 +333,49 @@ class ChainService:
|
|||||||
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
|
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── Real IP ──────────────────────────────────────────────────────────
|
# ── 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)
|
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:
|
if real_ip:
|
||||||
self._notify({"type": "real_ip", "ip": real_ip})
|
self._notify({"type": "real_ip", "ip": real_ip})
|
||||||
self._notify({"type": "log", "text": f"Your real 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:
|
else:
|
||||||
self._notify({"type": "log", "text": "Could not determine real IP — leak detection disabled."})
|
# 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 ──────────────────────────────────────────────
|
# ── Firewall kill-switch ──────────────────────────────────────────────
|
||||||
if self._settings.kill_switch_enabled:
|
if self._settings.kill_switch_enabled:
|
||||||
@@ -217,8 +421,36 @@ class ChainService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Pinned (manual) chain mode — skip pool management
|
# 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:
|
if self._settings.use_pinned_chain and self._settings.pinned_chain:
|
||||||
chain = list(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
|
rotation_num += 1
|
||||||
self._notify({"type": "rotation", "n": rotation_num})
|
self._notify({"type": "rotation", "n": rotation_num})
|
||||||
log.info("Pinned chain run: %d hops", len(chain))
|
log.info("Pinned chain run: %d hops", len(chain))
|
||||||
@@ -301,6 +533,21 @@ class ChainService:
|
|||||||
self._current_chain = list(chain)
|
self._current_chain = list(chain)
|
||||||
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
|
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
|
||||||
self._notify({"type": "phase", "phase": "gost_start"})
|
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()
|
listen = self._settings.listen_addr()
|
||||||
cmd = build_gost_cmd(gost, listen, chain)
|
cmd = build_gost_cmd(gost, listen, chain)
|
||||||
self._notify({
|
self._notify({
|
||||||
@@ -348,7 +595,9 @@ class ChainService:
|
|||||||
"text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…",
|
"text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…",
|
||||||
})
|
})
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
|
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")
|
log.debug("Initial exit IP check took %.2fs → %s", time.monotonic() - t0, exit_ip or "none")
|
||||||
|
|
||||||
if not exit_ip:
|
if not exit_ip:
|
||||||
@@ -358,19 +607,11 @@ class ChainService:
|
|||||||
self._proc = None
|
self._proc = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if real_ip and _is_same_network(exit_ip, real_ip):
|
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": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
|
||||||
self._notify({
|
self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."})
|
||||||
"type": "log",
|
log.warning("Chain leak: exit=%s real=%s vpn=%s — %s", exit_ip, real_ip, self._vpn.active, reason)
|
||||||
"text": (
|
|
||||||
f"Leak detected! Exit={exit_ip} shares network with real IP {real_ip} "
|
|
||||||
f"(same /16 subnet — chain not forwarding, likely exiting via VPN directly). Rotating."
|
|
||||||
),
|
|
||||||
})
|
|
||||||
log.warning(
|
|
||||||
"Subnet leak: exit=%s real=%s — chain proxy not forwarding. Blacklisting chain.",
|
|
||||||
exit_ip, real_ip,
|
|
||||||
)
|
|
||||||
# Blacklist the whole chain so we don't reuse broken proxies
|
# Blacklist the whole chain so we don't reuse broken proxies
|
||||||
fixed = self._manual_exit_url()
|
fixed = self._manual_exit_url()
|
||||||
for h in chain:
|
for h in chain:
|
||||||
@@ -382,15 +623,57 @@ class ChainService:
|
|||||||
self._proc = None
|
self._proc = None
|
||||||
return False
|
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": "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"✓ Chain healthy — Exit IP: {exit_ip}"})
|
||||||
|
self._notify({"type": "log", "text": f"✓ HTTPS tunnel OK — {https_msg}"})
|
||||||
|
|
||||||
self._notify({"type": "phase", "phase": "running"})
|
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(
|
set_system_proxy(
|
||||||
self._settings.local_host,
|
self._settings.local_host,
|
||||||
self._settings.local_port,
|
self._settings.local_port,
|
||||||
self._settings.proxy_bypass,
|
self._settings.proxy_bypass,
|
||||||
)
|
)
|
||||||
self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})
|
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 ───────────────────────────────────────────────
|
# ── Health monitor loop ───────────────────────────────────────────────
|
||||||
hc = int(self._settings.health_check_seconds)
|
hc = int(self._settings.health_check_seconds)
|
||||||
@@ -407,14 +690,24 @@ class ChainService:
|
|||||||
|
|
||||||
self._notify({"type": "log", "text": "Health check..."})
|
self._notify({"type": "log", "text": "Health check..."})
|
||||||
t1 = time.monotonic()
|
t1 = time.monotonic()
|
||||||
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
|
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")
|
log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")
|
||||||
|
|
||||||
if not exit_ip or (real_ip and _is_same_network(exit_ip, real_ip)):
|
if is_chain_leak(exit_ip, real_ip, self._vpn.active):
|
||||||
reason = (
|
reason = leak_reason(exit_ip, real_ip, self._vpn.active)
|
||||||
"exit IP gone" if not exit_ip
|
rem = self.sticky_remaining()
|
||||||
else f"exit {exit_ip} matches real network {real_ip} (VPN leak)"
|
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": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
|
||||||
self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
|
self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
|
||||||
break
|
break
|
||||||
@@ -627,6 +920,9 @@ class ChainService:
|
|||||||
if self._force_rotate.is_set():
|
if self._force_rotate.is_set():
|
||||||
self._force_rotate.clear()
|
self._force_rotate.clear()
|
||||||
self._notify({"type": "log", "text": "Manual rotate triggered."})
|
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"
|
return "rotate"
|
||||||
remaining = end - time.monotonic()
|
remaining = end - time.monotonic()
|
||||||
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
|
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
|
||||||
|
|||||||
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")
|
||||||
@@ -1,16 +1,62 @@
|
|||||||
"""Set / clear the Windows system-wide HTTP proxy via the registry + WinINet broadcast."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
import winreg
|
import winreg
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
_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
|
_SETTINGS_CHANGED = 39
|
||||||
_REFRESH = 37
|
_REFRESH = 37
|
||||||
|
|
||||||
|
_FLAG_DIRECT = 0x01
|
||||||
|
_FLAG_MANUAL_PROXY = 0x02
|
||||||
|
_FLAG_AUTO_CONFIG = 0x04
|
||||||
|
_FLAG_AUTO_DETECT = 0x08
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin() -> bool:
|
||||||
|
try:
|
||||||
|
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _broadcast() -> None:
|
def _broadcast() -> None:
|
||||||
"""Tell running apps (browsers, etc.) that proxy settings changed."""
|
"""Tell running apps (browsers, etc.) that proxy settings changed."""
|
||||||
@@ -22,8 +68,341 @@ def _broadcast() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def set_system_proxy(host: str, port: int, bypass: str = "localhost;127.*;10.*;192.168.*;<local>") -> None:
|
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]:
|
||||||
|
"""Return human-readable paths of Group Policy proxy locks that beat us.
|
||||||
|
|
||||||
|
Anything we find here will silently override our manual proxy. We do *not*
|
||||||
|
delete policy keys (admins set those deliberately) — we just surface them.
|
||||||
|
"""
|
||||||
|
hits: list[str] = []
|
||||||
|
proxy_value_names = ("ProxyServer", "ProxyEnable", "AutoConfigURL", "ProxyOverride")
|
||||||
|
for hive, path in _POLICY_PATHS:
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
|
||||||
|
for n in proxy_value_names:
|
||||||
|
try:
|
||||||
|
winreg.QueryValueEx(key, n)
|
||||||
|
hive_name = "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU"
|
||||||
|
hits.append(f"{hive_name}\\{path}\\{n}")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def _read_str(hive: int, path: str, name: str) -> str:
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
|
||||||
|
val, _ = winreg.QueryValueEx(key, name)
|
||||||
|
return str(val)
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_dword(hive: int, path: str, name: str) -> int | None:
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
|
||||||
|
val, _ = winreg.QueryValueEx(key, name)
|
||||||
|
return int(val)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_winhttp_proxy() -> str:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["netsh", "winhttp", "show", "proxy"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
for ln in (r.stdout or "").splitlines():
|
||||||
|
if "proxy" in ln.lower() and ":" in ln:
|
||||||
|
return ln.strip()
|
||||||
|
return (r.stdout or "").strip().splitlines()[-1] if r.stdout else ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_system_proxy() -> list[str]:
|
||||||
|
"""Return a list of human-readable lines covering every place proxy can
|
||||||
|
be configured. Logged on each engage so "chain green but browser leaks"
|
||||||
|
is debuggable without RegEdit gymnastics.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
per_user = _read_proxy_settings_per_user()
|
||||||
|
per_user_msg = (
|
||||||
|
"1 (HKCU honored)" if per_user == 1
|
||||||
|
else "0 (HKCU IGNORED — Server lock!)" if per_user == 0
|
||||||
|
else "(unset → defaults to HKCU)"
|
||||||
|
)
|
||||||
|
lines.append(f"ProxySettingsPerUser = {per_user_msg}")
|
||||||
|
|
||||||
|
en_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyEnable")
|
||||||
|
ps_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyServer")
|
||||||
|
lines.append(f"HKCU Enable={en_u} Server='{ps_u}'")
|
||||||
|
|
||||||
|
en_m = _read_dword(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyEnable")
|
||||||
|
ps_m = _read_str(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyServer")
|
||||||
|
lines.append(f"HKLM Enable={en_m} Server='{ps_m}'")
|
||||||
|
|
||||||
|
ac_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoConfigURL")
|
||||||
|
ad_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoDetect")
|
||||||
|
if ac_u or ad_u:
|
||||||
|
lines.append(f"WPAD/PAC: AutoConfigURL='{ac_u}' AutoDetect={ad_u}")
|
||||||
|
|
||||||
|
winhttp = _read_winhttp_proxy()
|
||||||
|
if winhttp:
|
||||||
|
lines.append(f"WinHTTP: {winhttp}")
|
||||||
|
|
||||||
|
pol = detect_policy_overrides()
|
||||||
|
if pol:
|
||||||
|
lines.append(f"Policy overrides (these BEAT us): {len(pol)} entries")
|
||||||
|
for p in pol[:5]:
|
||||||
|
lines.append(f" ! {p}")
|
||||||
|
if len(pol) > 5:
|
||||||
|
lines.append(f" ! …and {len(pol) - 5} more")
|
||||||
|
else:
|
||||||
|
lines.append("Policy overrides: none")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def set_system_proxy(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
bypass: str = "localhost;127.*;10.*;192.168.*;<local>",
|
||||||
|
) -> None:
|
||||||
proxy = f"{host}:{port}"
|
proxy = f"{host}:{port}"
|
||||||
|
|
||||||
|
# HKCU root
|
||||||
try:
|
try:
|
||||||
with winreg.OpenKey(
|
with winreg.OpenKey(
|
||||||
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
|
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
|
||||||
@@ -31,10 +410,44 @@ def set_system_proxy(host: str, port: int, bypass: str = "localhost;127.*;10.*;1
|
|||||||
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
|
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
|
||||||
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
|
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
|
||||||
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
|
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
|
||||||
_broadcast()
|
_wipe_autoconfig(key)
|
||||||
log.info("System proxy set to %s (bypass: %s)", proxy, bypass)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
log.exception("Failed to set system proxy")
|
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:
|
def clear_system_proxy() -> None:
|
||||||
@@ -43,11 +456,19 @@ def clear_system_proxy() -> None:
|
|||||||
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
|
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
|
||||||
) as key:
|
) as key:
|
||||||
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
|
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
|
||||||
_broadcast()
|
_wipe_autoconfig(key)
|
||||||
log.info("System proxy cleared")
|
|
||||||
except OSError:
|
except OSError:
|
||||||
log.exception("Failed to clear system proxy")
|
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:
|
def is_system_proxy_set() -> bool:
|
||||||
try:
|
try:
|
||||||
|
|||||||
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
|
||||||
@@ -1,36 +1,76 @@
|
|||||||
"""System-tray icon: green = healthy chain, red = broken/stopped, yellow = connecting."""
|
"""System tray: LED-style proxy on/off + hover tooltip with exit IP."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
import pystray
|
import pystray
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
|
||||||
|
_SIZE = 64
|
||||||
|
|
||||||
|
|
||||||
def _circle_icon(color: str, size: int = 64) -> Image.Image:
|
def _hex_rgb(h: str) -> tuple[int, int, int]:
|
||||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
h = h.lstrip("#")
|
||||||
draw = ImageDraw.Draw(img)
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||||
pad = 4
|
|
||||||
draw.ellipse([pad, pad, size - pad, size - pad], fill=color)
|
|
||||||
return img
|
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 = {
|
ICONS = {
|
||||||
"green": _circle_icon("#00e676"),
|
"green": _led_icon("#00e676", "#00e676"),
|
||||||
"red": _circle_icon("#ff1744"),
|
"red": _led_icon("#ff1744", "#ff1744"),
|
||||||
"yellow": _circle_icon("#ffab00"),
|
"yellow": _led_icon("#ffab00", "#ffab00"),
|
||||||
"gray": _circle_icon("#6c757d"),
|
"gray": _led_icon("#4a5568", "#3d4a5c", rim="#374151"),
|
||||||
}
|
}
|
||||||
|
|
||||||
TIPS = {
|
_STATE_LABEL = {
|
||||||
"green": "Proxy Chain: healthy",
|
"green": "PROXY ON",
|
||||||
"red": "Proxy Chain: broken / stopped",
|
"red": "PROXY OFF / ERROR",
|
||||||
"yellow": "Proxy Chain: connecting…",
|
"yellow": "CONNECTING…",
|
||||||
"gray": "Proxy Chain: idle",
|
"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:
|
class TrayIcon:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -44,20 +84,24 @@ class TrayIcon:
|
|||||||
self._icon: pystray.Icon | None = None
|
self._icon: pystray.Icon | None = None
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._state = "gray"
|
self._state = "gray"
|
||||||
|
self._exit_ip: str | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._thread and self._thread.is_alive():
|
if self._thread and self._thread.is_alive():
|
||||||
return
|
return
|
||||||
menu = pystray.Menu(
|
menu = pystray.Menu(
|
||||||
pystray.MenuItem("Show", self._show, default=True),
|
pystray.MenuItem("Show Proxy God", self._show, default=True),
|
||||||
pystray.MenuItem("Rotate now", self._rotate),
|
pystray.MenuItem("Rotate chain now", self._rotate),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Quit", self._quit),
|
pystray.MenuItem("Quit", self._quit),
|
||||||
)
|
)
|
||||||
|
with self._lock:
|
||||||
|
tip = _tooltip(self._state, self._exit_ip)
|
||||||
self._icon = pystray.Icon(
|
self._icon = pystray.Icon(
|
||||||
"ProxyChainManager",
|
"ProxyGod",
|
||||||
icon=ICONS[self._state],
|
icon=ICONS[self._state],
|
||||||
title=TIPS[self._state],
|
title=tip,
|
||||||
menu=menu,
|
menu=menu,
|
||||||
)
|
)
|
||||||
self._thread = threading.Thread(target=self._icon.run, daemon=True)
|
self._thread = threading.Thread(target=self._icon.run, daemon=True)
|
||||||
@@ -67,17 +111,30 @@ class TrayIcon:
|
|||||||
if self._icon:
|
if self._icon:
|
||||||
try:
|
try:
|
||||||
self._icon.stop()
|
self._icon.stop()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.warning("Tray icon stop failed: %s", exc)
|
||||||
|
|
||||||
def set_state(self, state: str) -> None:
|
def set_state(self, state: str, exit_ip: str | None = None) -> None:
|
||||||
"""state: 'green', 'red', 'yellow', 'gray'."""
|
"""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:
|
if state not in ICONS:
|
||||||
state = "gray"
|
state = "gray"
|
||||||
|
with self._lock:
|
||||||
self._state = state
|
self._state = state
|
||||||
|
if exit_ip is not None:
|
||||||
|
self._exit_ip = exit_ip.strip() if exit_ip else None
|
||||||
if self._icon:
|
if self._icon:
|
||||||
self._icon.icon = ICONS[state]
|
self._icon.icon = ICONS[state]
|
||||||
self._icon.title = TIPS[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:
|
def _show(self, icon: Any = None, item: Any = None) -> None:
|
||||||
self._on_show()
|
self._on_show()
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ import httpx
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Secondary fallback endpoints for IP resolution
|
# 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 = [
|
_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://api.ipify.org?format=json",
|
||||||
"https://httpbin.org/ip",
|
"https://httpbin.org/ip",
|
||||||
"https://ifconfig.me/ip",
|
"https://ifconfig.me/ip",
|
||||||
@@ -144,14 +152,25 @@ async def check_chain_exit_ip(
|
|||||||
listen_proxy: str,
|
listen_proxy: str,
|
||||||
check_url: str,
|
check_url: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
|
chain_hops: int = 3,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Query the IP-check URL through the local chain proxy.
|
"""Query the IP-check URL through the local chain proxy.
|
||||||
Bounded total time — never stacks one slow request per fallback URL forever."""
|
|
||||||
per = max(5.0, min(20.0, float(timeout_seconds)))
|
Bounded total time — never stacks one slow request per fallback URL forever.
|
||||||
budget = max(15.0, min(60.0, float(timeout_seconds) * 2 + 5.0))
|
Per-request timeout scales mildly with chain length so 5+ hop chains on
|
||||||
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:2]
|
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(
|
log.debug(
|
||||||
"check_chain_exit_ip: budget=%.1fs per_req=%.1fs fallback_count=%d",
|
"check_chain_exit_ip: hops=%d budget=%.1fs per_req=%.1fs fallback_count=%d",
|
||||||
|
hops,
|
||||||
budget,
|
budget,
|
||||||
per,
|
per,
|
||||||
len(urls),
|
len(urls),
|
||||||
@@ -218,3 +237,41 @@ async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str |
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
log.debug("get_direct_ip timed out after %.1fs", budget)
|
log.debug("get_direct_ip timed out after %.1fs", budget)
|
||||||
return None
|
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"
|
||||||
|
|||||||
216
proxy_chain_manager/vpn_detect.py
Normal file
216
proxy_chain_manager/vpn_detect.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Detect whether a VPN tunnel is active on Windows (any provider)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
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."""
|
||||||
|
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
|
||||||
90
proxy_chain_manager/win_compat.py
Normal file
90
proxy_chain_manager/win_compat.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""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
|
||||||
|
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:
|
||||||
|
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."""
|
||||||
|
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
|
||||||
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 |
25
releases/README.md
Normal file
25
releases/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Proxy God — Windows releases
|
||||||
|
|
||||||
|
Production builds live here after running `build_release.bat` from the repo root.
|
||||||
|
|
||||||
|
## Latest: v1.0.0
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `v1.0.0/ProxyChainManager.exe` | Main application (UAC admin, one-file PyInstaller build) |
|
||||||
|
| `v1.0.0/ProxyChainManager.exe.sha256` | SHA256 checksum |
|
||||||
|
| `v1.0.0/RELEASE_MANIFEST.json` | Version, git commit, build time, platform |
|
||||||
|
| `v1.0.0/SBOM.json` | Frozen dependency list at build time |
|
||||||
|
| `v1.0.0/requirements-frozen.txt` | Same as SBOM, pip freeze format |
|
||||||
|
| `v1.0.0/LICENSE` | MIT |
|
||||||
|
| `v1.0.0/OPERATOR_RUNBOOK.md` | Emergency ops (firewall disengage, etc.) |
|
||||||
|
| `ProxyGod-v1.0.0-windows-amd64.zip` | Full release archive |
|
||||||
|
|
||||||
|
### Verify download
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-FileHash -Algorithm SHA256 releases\v1.0.0\ProxyChainManager.exe
|
||||||
|
Get-Content releases\v1.0.0\ProxyChainManager.exe.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Build commit: see `RELEASE_MANIFEST.json` inside the folder.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
customtkinter>=5.2.0
|
customtkinter==5.2.2
|
||||||
httpx[socks]>=0.27.0
|
httpx[socks]==0.28.1
|
||||||
pystray>=0.19.0
|
pystray==0.19.5
|
||||||
pillow>=10.0.0
|
pillow==12.2.0
|
||||||
|
cryptography==48.0.0
|
||||||
|
|||||||
5
run.py
5
run.py
@@ -1,6 +1,11 @@
|
|||||||
"""Launcher for development (pythonw run.py) so scheduled task can find a stable path."""
|
"""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
|
from proxy_chain_manager.app import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
if "--version" in sys.argv or "-V" in sys.argv:
|
||||||
|
print(f"Proxy God {__version__}")
|
||||||
|
sys.exit(0)
|
||||||
main()
|
main()
|
||||||
|
|||||||
221
scripts/_build_common.ps1
Normal file
221
scripts/_build_common.ps1
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
Shared build helpers for Proxy God / ProxyChainManager.
|
||||||
|
Dot-sourced by setup_and_build.ps1 and release_build.ps1 — do not run directly.
|
||||||
|
#>
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
function Get-RepoRoot {
|
||||||
|
# This file lives in scripts/ — parent is the repository root.
|
||||||
|
return (Split-Path -Parent $PSScriptRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-PythonExe {
|
||||||
|
if (Get-Command py -ErrorAction SilentlyContinue) {
|
||||||
|
try {
|
||||||
|
$out = (& py -3 -c "import sys; print(sys.executable)" 2>$null).Trim()
|
||||||
|
if ($out -and (Test-Path -LiteralPath $out)) { return $out }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
$p = Get-Command python.exe -ErrorAction SilentlyContinue
|
||||||
|
if ($p) { return $p.Source }
|
||||||
|
throw @(
|
||||||
|
"Python 3.10+ not found (tried 'py -3' and 'python'). Install, then re-run:",
|
||||||
|
" winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements",
|
||||||
|
"Or https://www.python.org/downloads/ (enable 'Add python.exe to PATH')."
|
||||||
|
) -join "`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GitCommit {
|
||||||
|
try {
|
||||||
|
$c = (git -C (Get-RepoRoot) rev-parse --short HEAD 2>$null).Trim()
|
||||||
|
if ($c) { return $c }
|
||||||
|
} catch { }
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ReleaseVersion {
|
||||||
|
param([string]$Override = "")
|
||||||
|
if ($Override) { return $Override.Trim() }
|
||||||
|
$root = Get-RepoRoot
|
||||||
|
try {
|
||||||
|
$tag = (git -C $root describe --tags --exact-match 2>$null).Trim()
|
||||||
|
if ($tag) { return $tag.TrimStart("v") }
|
||||||
|
} catch { }
|
||||||
|
try {
|
||||||
|
$desc = (git -C $root describe --tags --always --dirty 2>$null).Trim()
|
||||||
|
if ($desc) { return ($desc -replace '^v', '') }
|
||||||
|
} catch { }
|
||||||
|
$py = Resolve-PythonExe
|
||||||
|
$v = (& $py -c "from proxy_chain_manager import __version__; print(__version__)" 2>$null).Trim()
|
||||||
|
if ($v) { return $v }
|
||||||
|
return "0.0.0-dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-BuildDependencies {
|
||||||
|
param([string]$PythonExe, [string]$Root)
|
||||||
|
Write-Host "`n== pip (upgrade) =="
|
||||||
|
& $PythonExe -m pip install --upgrade pip
|
||||||
|
Write-Host "`n== runtime dependencies =="
|
||||||
|
& $PythonExe -m pip install -r (Join-Path $Root "requirements.txt")
|
||||||
|
Write-Host "`n== build dependencies =="
|
||||||
|
& $PythonExe -m pip install -r (Join-Path $Root "dev-requirements.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-UnitTests {
|
||||||
|
param([string]$PythonExe, [string]$Root)
|
||||||
|
Write-Host "`n== compileall =="
|
||||||
|
& $PythonExe -m compileall -q (Join-Path $Root "proxy_chain_manager")
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "compileall failed (exit $LASTEXITCODE)" }
|
||||||
|
Write-Host "`n== unittest discover =="
|
||||||
|
Push-Location $Root
|
||||||
|
try {
|
||||||
|
& $PythonExe -m unittest discover -s tests -v
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "unittest failed (exit $LASTEXITCODE)" }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-StageBundledGost {
|
||||||
|
param([string]$Root)
|
||||||
|
Write-Host "`n== Stage bundled GOST =="
|
||||||
|
$prep = Join-Path $Root "scripts\prepare_bundled_gost.ps1"
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File $prep
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "prepare_bundled_gost.ps1 failed (exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-GenerateVersionInfo {
|
||||||
|
param(
|
||||||
|
[string]$PythonExe,
|
||||||
|
[string]$Root,
|
||||||
|
[string]$Version,
|
||||||
|
[string]$Commit = ""
|
||||||
|
)
|
||||||
|
Write-Host "`n== Version metadata ($Version) =="
|
||||||
|
$gen = Join-Path $Root "scripts\generate_version_info.py"
|
||||||
|
$out = Join-Path $Root "build\version_info.txt"
|
||||||
|
$args = @($gen, "--version", $Version, "--out", $out)
|
||||||
|
if ($Commit) { $args += @("--commit", $Commit) }
|
||||||
|
& $PythonExe @args
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "generate_version_info.py failed (exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-PyInstallerBuild {
|
||||||
|
param([string]$PythonExe, [string]$Root)
|
||||||
|
Write-Host "`n== PyInstaller (ProxyChainManager.spec) =="
|
||||||
|
$spec = Join-Path $Root "ProxyChainManager.spec"
|
||||||
|
if (-not (Test-Path $spec)) { throw "Spec not found: $spec" }
|
||||||
|
& $PythonExe -m PyInstaller --noconfirm --clean $spec
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed (exit $LASTEXITCODE)" }
|
||||||
|
$exe = Join-Path $Root "dist\ProxyChainManager.exe"
|
||||||
|
if (-not (Test-Path $exe)) { throw "Build failed: missing $exe" }
|
||||||
|
return $exe
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-CodeSign {
|
||||||
|
param([string]$ExePath)
|
||||||
|
$cert = $env:SIGN_CERT_PATH
|
||||||
|
if (-not $cert) {
|
||||||
|
Write-Host " (signing skipped - set SIGN_CERT_PATH to a .pfx to Authenticode-sign)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $cert)) { throw "SIGN_CERT_PATH not found: $cert" }
|
||||||
|
$signtool = "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe"
|
||||||
|
if (-not (Test-Path $signtool)) {
|
||||||
|
$signtool = (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
}
|
||||||
|
if (-not $signtool) { throw "signtool.exe not found - install Windows SDK" }
|
||||||
|
Write-Host "`n== Authenticode sign =="
|
||||||
|
$args = @("sign", "/fd", "SHA256", "/f", $cert, "/tr", "http://timestamp.digicert.com", "/td", "SHA256")
|
||||||
|
if ($env:SIGN_CERT_PASSWORD) {
|
||||||
|
$args += @("/p", $env:SIGN_CERT_PASSWORD)
|
||||||
|
}
|
||||||
|
$args += $ExePath
|
||||||
|
& $signtool @args
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed (exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-ExeHashSidecar {
|
||||||
|
param([string]$ExePath)
|
||||||
|
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ExePath).Hash.ToLower()
|
||||||
|
$sidecar = "$ExePath.sha256"
|
||||||
|
"$hash *$(Split-Path -Leaf $ExePath)" | Out-File -FilePath $sidecar -Encoding ascii -Force
|
||||||
|
Write-Host "SHA256: $hash -> $sidecar"
|
||||||
|
return @{ Hash = $hash; Sidecar = $sidecar }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Sbom {
|
||||||
|
param(
|
||||||
|
[string]$PythonExe,
|
||||||
|
[string]$OutDir,
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
Write-Host "`n== SBOM =="
|
||||||
|
$freeze = Join-Path $OutDir "requirements-frozen.txt"
|
||||||
|
& $PythonExe -m pip freeze | Out-File -FilePath $freeze -Encoding utf8
|
||||||
|
$sbom = Join-Path $OutDir "SBOM.json"
|
||||||
|
$pkgs = @()
|
||||||
|
Get-Content $freeze | ForEach-Object {
|
||||||
|
if ($_ -match '^([^=]+)==(.+)$') {
|
||||||
|
$pkgs += @{ name = $Matches[1]; version = $Matches[2] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$manifest = @{
|
||||||
|
product = "Proxy God"
|
||||||
|
version = $Version
|
||||||
|
generated = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
|
python = (& $PythonExe -c "import sys; print(sys.version.split()[0])").Trim()
|
||||||
|
packages = $pkgs
|
||||||
|
}
|
||||||
|
($manifest | ConvertTo-Json -Depth 4) | Out-File -FilePath $sbom -Encoding utf8
|
||||||
|
Write-Host "Wrote $sbom and $freeze"
|
||||||
|
return @{ Sbom = $sbom; Freeze = $freeze }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-ReleaseManifest {
|
||||||
|
param(
|
||||||
|
[string]$OutDir,
|
||||||
|
[string]$Version,
|
||||||
|
[string]$Commit,
|
||||||
|
[string]$ExePath,
|
||||||
|
[string]$Sha256,
|
||||||
|
[hashtable]$Extra = @{}
|
||||||
|
)
|
||||||
|
$manifest = @{
|
||||||
|
product = "Proxy God"
|
||||||
|
executable = "ProxyChainManager.exe"
|
||||||
|
version = $Version
|
||||||
|
git_commit = $Commit
|
||||||
|
built_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
|
platform = "windows-amd64"
|
||||||
|
sha256 = $Sha256
|
||||||
|
uac_admin = $true
|
||||||
|
bundled_gost = $true
|
||||||
|
pyinstaller = "6.10.0"
|
||||||
|
}
|
||||||
|
foreach ($k in $Extra.Keys) { $manifest[$k] = $Extra[$k] }
|
||||||
|
$path = Join-Path $OutDir "RELEASE_MANIFEST.json"
|
||||||
|
($manifest | ConvertTo-Json -Depth 4) | Out-File -FilePath $path -Encoding utf8
|
||||||
|
Write-Host "Wrote $path"
|
||||||
|
return $path
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-ReleaseZip {
|
||||||
|
param(
|
||||||
|
[string]$ReleaseDir,
|
||||||
|
[string]$Version,
|
||||||
|
[string]$Root
|
||||||
|
)
|
||||||
|
$zipName = "ProxyGod-v$Version-windows-amd64.zip"
|
||||||
|
$zipPath = Join-Path $Root "releases\$zipName"
|
||||||
|
$releasesRoot = Split-Path $zipPath -Parent
|
||||||
|
if (-not (Test-Path $releasesRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $releasesRoot | Out-Null
|
||||||
|
}
|
||||||
|
if (Test-Path $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
|
||||||
|
Compress-Archive -Path (Join-Path $ReleaseDir "*") -DestinationPath $zipPath -Force
|
||||||
|
Write-Host "Release zip: $zipPath"
|
||||||
|
return $zipPath
|
||||||
|
}
|
||||||
74
scripts/generate_version_info.py
Normal file
74
scripts/generate_version_info.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a PyInstaller version-info file for ProxyChainManager.exe.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/generate_version_info.py --version 1.2.3 --out build/version_info.txt
|
||||||
|
|
||||||
|
The output is consumed by ProxyChainManager.spec when the file exists.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(version: str) -> tuple[int, int, int, int]:
|
||||||
|
parts = re.findall(r"\d+", version)
|
||||||
|
nums = [int(x) for x in parts[:4]]
|
||||||
|
while len(nums) < 4:
|
||||||
|
nums.append(0)
|
||||||
|
return nums[0], nums[1], nums[2], nums[3]
|
||||||
|
|
||||||
|
|
||||||
|
def build_version_info(version: str, commit: str = "") -> str:
|
||||||
|
filevers = prodvers = _parse_version(version)
|
||||||
|
commit_suffix = f" ({commit})" if commit else ""
|
||||||
|
return f"""# UTF-8
|
||||||
|
# Generated by scripts/generate_version_info.py - do not edit by hand.
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers={filevers},
|
||||||
|
prodvers={prodvers},
|
||||||
|
mask=0x3f,
|
||||||
|
flags=0x0,
|
||||||
|
OS=0x40004,
|
||||||
|
fileType=0x1,
|
||||||
|
subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo(
|
||||||
|
[
|
||||||
|
StringTable(
|
||||||
|
u'040904B0',
|
||||||
|
[StringStruct(u'CompanyName', u'Proxy God'),
|
||||||
|
StringStruct(u'FileDescription', u'Proxy God - multi-hop proxy chain manager'),
|
||||||
|
StringStruct(u'FileVersion', u'{version}{commit_suffix}'),
|
||||||
|
StringStruct(u'InternalName', u'ProxyChainManager'),
|
||||||
|
StringStruct(u'LegalCopyright', u'MIT License'),
|
||||||
|
StringStruct(u'OriginalFilename', u'ProxyChainManager.exe'),
|
||||||
|
StringStruct(u'ProductName', u'Proxy God'),
|
||||||
|
StringStruct(u'ProductVersion', u'{version}{commit_suffix}')])
|
||||||
|
]
|
||||||
|
),
|
||||||
|
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--version", required=True)
|
||||||
|
ap.add_argument("--commit", default="")
|
||||||
|
ap.add_argument("--out", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
out = Path(args.out)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(build_version_info(args.version, args.commit), encoding="utf-8")
|
||||||
|
print(f"Wrote {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
58
scripts/prepare_bundled_gost.ps1
Normal file
58
scripts/prepare_bundled_gost.ps1
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Pre-build step: download gost.exe v3.2.6, verify SHA256, and stage it in
|
||||||
|
proxy_chain_manager/_bundled/gost.exe so PyInstaller can bundle it.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Runs before scripts\setup_and_build.ps1. After this step, the spec file
|
||||||
|
includes _bundled/gost.exe as a data file inside the exe and ensure_gost()
|
||||||
|
copies it out to %LOCALAPPDATA% on first run.
|
||||||
|
#>
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
$bundleDir = Join-Path $root "proxy_chain_manager\_bundled"
|
||||||
|
$bundleExe = Join-Path $bundleDir "gost.exe"
|
||||||
|
$expectedZipHash = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b"
|
||||||
|
$zipUrl = "https://github.com/go-gost/gost/releases/download/v3.2.6/gost_3.2.6_windows_amd64.zip"
|
||||||
|
|
||||||
|
if (-not (Test-Path $bundleDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $bundleDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip if already staged and valid
|
||||||
|
if (Test-Path $bundleExe) {
|
||||||
|
Write-Host "Bundled gost.exe already present at $bundleExe"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempZip = Join-Path $env:TEMP "gost_bundle.zip"
|
||||||
|
Write-Host "Downloading $zipUrl ..."
|
||||||
|
Invoke-WebRequest -Uri $zipUrl -OutFile $tempZip -UseBasicParsing
|
||||||
|
|
||||||
|
$actualZipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempZip).Hash.ToLower()
|
||||||
|
if ($actualZipHash -ne $expectedZipHash) {
|
||||||
|
Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
|
||||||
|
throw "GOST zip SHA256 mismatch! expected=$expectedZipHash actual=$actualZipHash"
|
||||||
|
}
|
||||||
|
Write-Host "Zip SHA256 OK: $actualZipHash"
|
||||||
|
|
||||||
|
$tempExtract = Join-Path $env:TEMP "gost_bundle_extract"
|
||||||
|
if (Test-Path $tempExtract) { Remove-Item -Recurse -Force $tempExtract }
|
||||||
|
Expand-Archive -LiteralPath $tempZip -DestinationPath $tempExtract -Force
|
||||||
|
|
||||||
|
$extractedExe = Get-ChildItem -Path $tempExtract -Filter "gost.exe" -Recurse | Select-Object -First 1
|
||||||
|
if (-not $extractedExe) {
|
||||||
|
throw "gost.exe not found inside extracted zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $extractedExe.FullName -Destination $bundleExe -Force
|
||||||
|
|
||||||
|
# Pin the exe hash so runtime can verify the copy that gets dropped to %LOCALAPPDATA%
|
||||||
|
$exeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $bundleExe).Hash.ToLower()
|
||||||
|
"$exeHash" | Out-File -FilePath "$bundleExe.sha256" -Encoding ascii -Force
|
||||||
|
|
||||||
|
Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Recurse -Force $tempExtract -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "Bundled gost.exe: $bundleExe ($exeHash)"
|
||||||
107
scripts/release_build.ps1
Normal file
107
scripts/release_build.ps1
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Production release build for Proxy God (ProxyChainManager.exe).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Full pipeline:
|
||||||
|
1. Resolve Python 3.10+
|
||||||
|
2. Install pinned runtime + build deps
|
||||||
|
3. Run compileall + unittest (gate)
|
||||||
|
4. Stage SHA-verified bundled gost.exe
|
||||||
|
5. Generate Windows VERSIONINFO for the exe
|
||||||
|
6. PyInstaller one-file build (ProxyChainManager.spec)
|
||||||
|
7. Optional Authenticode sign (SIGN_CERT_PATH / SIGN_CERT_PASSWORD)
|
||||||
|
8. SHA256 sidecar + SBOM + RELEASE_MANIFEST.json
|
||||||
|
9. Zip release folder -> releases/ProxyGod-v{version}-windows-amd64.zip
|
||||||
|
|
||||||
|
.PARAMETER Version
|
||||||
|
Override semver (default: git tag > git describe > __version__).
|
||||||
|
|
||||||
|
.PARAMETER SkipTests
|
||||||
|
Skip compileall/unittest (not recommended for production).
|
||||||
|
|
||||||
|
.PARAMETER SkipSign
|
||||||
|
Skip Authenticode even if SIGN_CERT_PATH is set.
|
||||||
|
|
||||||
|
.PARAMETER NoZip
|
||||||
|
Skip creating the release zip archive.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\release_build.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\release_build.ps1 -Version 1.2.0
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Version = "",
|
||||||
|
[switch]$SkipTests,
|
||||||
|
[switch]$SkipSign,
|
||||||
|
[switch]$NoZip
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot\_build_common.ps1"
|
||||||
|
|
||||||
|
$root = Get-RepoRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
$py = Resolve-PythonExe
|
||||||
|
Write-Host "Using: $py"
|
||||||
|
& $py -c "import sys; assert sys.version_info >= (3, 10); print(sys.version)"
|
||||||
|
|
||||||
|
$ver = Get-ReleaseVersion -Override $Version
|
||||||
|
$commit = Get-GitCommit
|
||||||
|
Write-Host "Release version: $ver (commit $commit)"
|
||||||
|
|
||||||
|
Install-BuildDependencies -PythonExe $py -Root $root
|
||||||
|
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Invoke-UnitTests -PythonExe $py -Root $root
|
||||||
|
} else {
|
||||||
|
Write-Warning "Skipping test gate (-SkipTests)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-StageBundledGost -Root $root
|
||||||
|
Invoke-GenerateVersionInfo -PythonExe $py -Root $root -Version $ver -Commit $commit
|
||||||
|
|
||||||
|
$exe = Invoke-PyInstallerBuild -PythonExe $py -Root $root
|
||||||
|
|
||||||
|
if (-not $SkipSign) {
|
||||||
|
Invoke-CodeSign -ExePath $exe
|
||||||
|
}
|
||||||
|
|
||||||
|
$hashInfo = Write-ExeHashSidecar -ExePath $exe
|
||||||
|
|
||||||
|
# Assemble release directory (versioned folder under releases/)
|
||||||
|
$releaseDir = Join-Path $root "releases\v$ver"
|
||||||
|
if (Test-Path $releaseDir) { Remove-Item -Recurse -Force $releaseDir }
|
||||||
|
New-Item -ItemType Directory -Path $releaseDir | Out-Null
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $exe -Destination (Join-Path $releaseDir "ProxyChainManager.exe") -Force
|
||||||
|
Copy-Item -LiteralPath $hashInfo.Sidecar -Destination (Join-Path $releaseDir "ProxyChainManager.exe.sha256") -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root "LICENSE") -Destination $releaseDir -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root "docs\OPERATOR_RUNBOOK.md") -Destination $releaseDir -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$sbom = Write-Sbom -PythonExe $py -OutDir $releaseDir -Version $ver
|
||||||
|
Write-ReleaseManifest -OutDir $releaseDir -Version $ver -Commit $commit `
|
||||||
|
-ExePath $exe -Sha256 $hashInfo.Hash -Extra @{
|
||||||
|
sbom_file = (Split-Path -Leaf $sbom.Sbom)
|
||||||
|
requirements_frozen = (Split-Path -Leaf $sbom.Freeze)
|
||||||
|
}
|
||||||
|
|
||||||
|
# dist/ sidecar is already written by Write-ExeHashSidecar on $exe
|
||||||
|
|
||||||
|
if (-not $NoZip) {
|
||||||
|
$zip = New-ReleaseZip -ReleaseDir $releaseDir -Version $ver -Root $root
|
||||||
|
Write-Host "`n== Release complete =="
|
||||||
|
Write-Host " Folder: $releaseDir"
|
||||||
|
Write-Host " Zip: $zip"
|
||||||
|
} else {
|
||||||
|
Write-Host "`n== Release complete (no zip) =="
|
||||||
|
Write-Host " Folder: $releaseDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nVerify:"
|
||||||
|
Write-Host " Get-FileHash -Algorithm SHA256 releases\v$ver\ProxyChainManager.exe"
|
||||||
@@ -1,63 +1,48 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
After git pull: upgrade pip, install deps, build ProxyChainManager.exe, copy to Desktop, create "Proxy God.lnk".
|
Developer build: deps, tests (optional), PyInstaller, copy to Desktop, shortcut.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Requires Python 3.10+ on PATH (python.exe) or the Windows py launcher (py -3).
|
Lighter than release_build.ps1 — skips SBOM/zip/manifest. For daily dev iteration.
|
||||||
Does not auto-install Python; see README for winget one-liner if needed.
|
Production releases: use scripts\release_build.ps1 or build_release.bat
|
||||||
#>
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
|
||||||
Set-Location $root
|
|
||||||
|
|
||||||
function Resolve-PythonExe {
|
.PARAMETER SkipTests
|
||||||
if (Get-Command py -ErrorAction SilentlyContinue) {
|
Skip unittest gate (faster iteration).
|
||||||
try {
|
#>
|
||||||
$out = (& py -3 -c "import sys; print(sys.executable)" 2>$null).Trim()
|
[CmdletBinding()]
|
||||||
if ($out -and (Test-Path -LiteralPath $out)) { return $out }
|
param([switch]$SkipTests)
|
||||||
} catch { }
|
|
||||||
}
|
$ErrorActionPreference = "Stop"
|
||||||
$p = Get-Command python.exe -ErrorAction SilentlyContinue
|
. "$PSScriptRoot\_build_common.ps1"
|
||||||
if ($p) { return $p.Source }
|
|
||||||
throw (
|
$root = Get-RepoRoot
|
||||||
"Python 3.10+ not found (tried 'py -3' and 'python'). Install, then re-run:`n" +
|
Set-Location $root
|
||||||
" winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements`n" +
|
|
||||||
"Or https://www.python.org/downloads/ (enable 'Add python.exe to PATH')."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
$py = Resolve-PythonExe
|
$py = Resolve-PythonExe
|
||||||
Write-Host "Using: $py"
|
Write-Host "Using: $py"
|
||||||
& $py -c "import sys; assert sys.version_info >= (3, 10), 'Need Python 3.10+'; print(sys.version)"
|
& $py -c "import sys; assert sys.version_info >= (3, 10); print(sys.version)"
|
||||||
|
|
||||||
Write-Host "`n== pip (upgrade) =="
|
$ver = Get-ReleaseVersion
|
||||||
& $py -m pip install --upgrade pip
|
Install-BuildDependencies -PythonExe $py -Root $root
|
||||||
|
|
||||||
Write-Host "`n== dependencies + PyInstaller =="
|
if (-not $SkipTests) {
|
||||||
& $py -m pip install -r "$root\requirements.txt"
|
Invoke-UnitTests -PythonExe $py -Root $root
|
||||||
& $py -m pip install pyinstaller
|
|
||||||
|
|
||||||
Write-Host "`n== PyInstaller =="
|
|
||||||
& $py -m PyInstaller --noconfirm --clean `
|
|
||||||
--onefile --windowed `
|
|
||||||
--uac-admin `
|
|
||||||
--name ProxyChainManager `
|
|
||||||
--collect-all customtkinter `
|
|
||||||
--hidden-import pystray._win32 `
|
|
||||||
"$root\run.py"
|
|
||||||
|
|
||||||
$distExe = Join-Path $root "dist\ProxyChainManager.exe"
|
|
||||||
if (-not (Test-Path $distExe)) {
|
|
||||||
throw "Build failed: missing $distExe"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Invoke-StageBundledGost -Root $root
|
||||||
|
Invoke-GenerateVersionInfo -PythonExe $py -Root $root -Version $ver -Commit (Get-GitCommit)
|
||||||
|
|
||||||
|
$exe = Invoke-PyInstallerBuild -PythonExe $py -Root $root
|
||||||
|
$hashInfo = Write-ExeHashSidecar -ExePath $exe
|
||||||
|
|
||||||
$desk = [Environment]::GetFolderPath("Desktop")
|
$desk = [Environment]::GetFolderPath("Desktop")
|
||||||
$deskExe = Join-Path $desk "ProxyChainManager.exe"
|
$deskExe = Join-Path $desk "ProxyChainManager.exe"
|
||||||
Copy-Item -LiteralPath $distExe -Destination $deskExe -Force
|
Copy-Item -LiteralPath $exe -Destination $deskExe -Force
|
||||||
Write-Host "Copied: $deskExe"
|
Copy-Item -LiteralPath $hashInfo.Sidecar -Destination "$deskExe.sha256" -Force
|
||||||
|
Write-Host "Copied: $deskExe (+ .sha256)"
|
||||||
|
|
||||||
Write-Host "`n== Desktop shortcut =="
|
Write-Host "`n== Desktop shortcut =="
|
||||||
& powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\create_desktop_shortcut.ps1"
|
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root "scripts\create_desktop_shortcut.ps1")
|
||||||
|
|
||||||
Write-Host "`nDone. Launch from Desktop: Proxy God.lnk (or ProxyChainManager.exe)"
|
Write-Host "`nDone. Launch from Desktop: Proxy God.lnk (or ProxyChainManager.exe)"
|
||||||
|
|||||||
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()
|
||||||
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from proxy_chain_manager.service import _is_same_network
|
from proxy_chain_manager.leak_detect import is_chain_leak, is_same_subnet
|
||||||
|
|
||||||
from proxy_chain_manager.config import (
|
from proxy_chain_manager.config import (
|
||||||
Settings,
|
Settings,
|
||||||
merge_proxy_credentials,
|
merge_proxy_credentials,
|
||||||
normalize_proxy_url,
|
normalize_proxy_url,
|
||||||
|
parse_exit_proxy_input,
|
||||||
redact_proxy_url,
|
redact_proxy_url,
|
||||||
sanitize_settings,
|
sanitize_settings,
|
||||||
split_proxy_for_edit,
|
split_proxy_for_edit,
|
||||||
@@ -17,6 +18,7 @@ from proxy_chain_manager.config import (
|
|||||||
from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries
|
from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries
|
||||||
from proxy_chain_manager.validator import (
|
from proxy_chain_manager.validator import (
|
||||||
check_chain_exit_ip,
|
check_chain_exit_ip,
|
||||||
|
check_https_tunnel,
|
||||||
get_direct_ip,
|
get_direct_ip,
|
||||||
validate_proxies,
|
validate_proxies,
|
||||||
)
|
)
|
||||||
@@ -24,20 +26,34 @@ from proxy_chain_manager.validator import (
|
|||||||
|
|
||||||
class TestSameNetwork(unittest.TestCase):
|
class TestSameNetwork(unittest.TestCase):
|
||||||
def test_exact_match(self) -> None:
|
def test_exact_match(self) -> None:
|
||||||
self.assertTrue(_is_same_network("1.2.3.4", "1.2.3.4"))
|
self.assertTrue(is_same_subnet("1.2.3.4", "1.2.3.4"))
|
||||||
|
|
||||||
def test_same_slash16(self) -> None:
|
def test_same_slash16(self) -> None:
|
||||||
self.assertTrue(_is_same_network("185.220.101.5", "185.220.200.9"))
|
self.assertTrue(is_same_subnet("185.220.101.5", "185.220.200.9"))
|
||||||
|
|
||||||
def test_different_slash16(self) -> None:
|
def test_different_slash16(self) -> None:
|
||||||
self.assertFalse(_is_same_network("185.220.101.5", "104.28.3.99"))
|
self.assertFalse(is_same_subnet("185.220.101.5", "104.28.3.99"))
|
||||||
|
|
||||||
def test_none_safe(self) -> None:
|
def test_none_safe(self) -> None:
|
||||||
self.assertFalse(_is_same_network(None, "1.2.3.4"))
|
self.assertFalse(is_same_subnet(None, "1.2.3.4"))
|
||||||
self.assertFalse(_is_same_network("1.2.3.4", None))
|
self.assertFalse(is_same_subnet("1.2.3.4", None))
|
||||||
|
|
||||||
def test_non_ipv4_safe(self) -> None:
|
def test_non_ipv4_safe(self) -> None:
|
||||||
self.assertFalse(_is_same_network("not-an-ip", "1.2.3.4"))
|
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):
|
class TestConfig(unittest.TestCase):
|
||||||
@@ -73,6 +89,45 @@ class TestConfig(unittest.TestCase):
|
|||||||
self.assertTrue(changed)
|
self.assertTrue(changed)
|
||||||
self.assertGreater(len(s2.sources), 0)
|
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:
|
def test_sanitize_clamps_extremes(self) -> None:
|
||||||
s = Settings(
|
s = Settings(
|
||||||
chain_length=0,
|
chain_length=0,
|
||||||
@@ -152,6 +207,14 @@ class TestValidator(unittest.TestCase):
|
|||||||
self.skipTest("could not reach ipify (offline or blocked)")
|
self.skipTest("could not reach ipify (offline or blocked)")
|
||||||
self.assertGreater(len(ip), 4)
|
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:
|
def test_check_chain_exit_ip_invalid_proxy_quick(self) -> None:
|
||||||
async def run() -> str | None:
|
async def run() -> str | None:
|
||||||
return await check_chain_exit_ip(
|
return await check_chain_exit_ip(
|
||||||
@@ -163,5 +226,199 @@ class TestValidator(unittest.TestCase):
|
|||||||
self.assertIsNone(asyncio.run(run()))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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()
|
||||||
Reference in New Issue
Block a user