fix: audit round 2 - DPAPI secrets, pinned hop probe, gost exe hash, admin guard, PID-scoped browser tracking, emergency disengage button, build sidecar
This commit is contained in:
@@ -168,7 +168,8 @@ def _main_inner() -> None:
|
||||
ctk.set_default_color_theme("dark-blue")
|
||||
|
||||
root = ctk.CTk()
|
||||
root.title("Proxy God v2")
|
||||
from . import __version__ as _APP_VERSION
|
||||
root.title(f"Proxy God v{_APP_VERSION}")
|
||||
root.geometry("1080x860")
|
||||
root.minsize(900, 720)
|
||||
root.configure(fg_color=BG)
|
||||
@@ -225,6 +226,7 @@ def _main_inner() -> None:
|
||||
browser = BrowserSession()
|
||||
browser_should_run = [False]
|
||||
browser_last_launch_ts = [0.0]
|
||||
browser_relaunch_count = [0]
|
||||
service_running = [False]
|
||||
|
||||
# ── tray ─────────────────────────────────────────────────────────────────
|
||||
@@ -305,6 +307,26 @@ def _main_inner() -> None:
|
||||
|
||||
def _start() -> None:
|
||||
_save_settings()
|
||||
# Hard-stop: if the kill-switch is enabled but we are not admin, the
|
||||
# firewall lockdown is silently skipped — which contradicts the
|
||||
# README's "fail closed" guarantee. Refuse to start in that mode.
|
||||
try:
|
||||
from .firewall import is_admin as _is_admin
|
||||
except Exception:
|
||||
_is_admin = lambda: True # noqa: E731
|
||||
if svc.settings.kill_switch_enabled and not _is_admin():
|
||||
from tkinter import messagebox
|
||||
if messagebox.askyesno(
|
||||
"Kill-switch requires Admin",
|
||||
"Kill-switch is enabled in Settings but this process is not "
|
||||
"running as Administrator.\n\n"
|
||||
"Continuing now will start the chain WITHOUT the firewall "
|
||||
"lockdown — your traffic will not 'fail closed' if the chain "
|
||||
"drops.\n\n"
|
||||
"Start anyway?",
|
||||
):
|
||||
svc.start()
|
||||
return
|
||||
svc.start()
|
||||
|
||||
start_btn = _btn(topbar, "▶ Start", _start, w=86)
|
||||
@@ -1442,12 +1464,14 @@ def _main_inner() -> None:
|
||||
_save_settings()
|
||||
cfg = _browser_cfg()
|
||||
browser_should_run[0] = True
|
||||
browser_relaunch_count[0] = 0 # user-initiated launch resets the cap
|
||||
if _launch_browser_with_cfg(cfg):
|
||||
browser_last_launch_ts[0] = time.monotonic()
|
||||
|
||||
def _stop_browser() -> None:
|
||||
cfg = _browser_cfg()
|
||||
browser_should_run[0] = False
|
||||
browser_relaunch_count[0] = 0
|
||||
ok, msg = browser.stop(dispose=cfg.disposable_profile)
|
||||
_log(msg)
|
||||
_refresh_browser_status()
|
||||
@@ -2637,6 +2661,34 @@ def _main_inner() -> None:
|
||||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||||
).pack(side="left")
|
||||
|
||||
# Emergency disengage — for crashes where the kill-switch lingers and
|
||||
# the operator needs internet back fast without restarting the app.
|
||||
def _emergency_disengage_now() -> None:
|
||||
from tkinter import messagebox
|
||||
from .firewall import emergency_disengage as _ed, is_engaged as _ie
|
||||
if not _ie():
|
||||
messagebox.showinfo("Kill-switch", "No Proxy God firewall rules detected — nothing to disengage.")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Emergency disengage",
|
||||
"Remove ALL Proxy God firewall rules and restore normal outbound traffic?\n\n"
|
||||
"Use this only when the chain has dropped and the kill-switch is "
|
||||
"blocking everything.",
|
||||
):
|
||||
return
|
||||
_ed()
|
||||
messagebox.showinfo("Kill-switch", "Firewall rules removed.")
|
||||
_log("Emergency disengage: firewall rules removed by operator.")
|
||||
|
||||
ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||||
ks_btn_row.pack(fill="x", padx=12, pady=(0, 8))
|
||||
_btn(
|
||||
ks_btn_row, "⚠ Emergency disengage firewall now",
|
||||
_emergency_disengage_now,
|
||||
w=320, h=28,
|
||||
fg_color="#7f1d1d", hover_color="#991b1b",
|
||||
).pack(side="left")
|
||||
|
||||
def _apply_point_and_shoot() -> None:
|
||||
"""Simple safe defaults: secure + low-friction launch profile."""
|
||||
use_manual_var.set(True)
|
||||
@@ -2921,9 +2973,18 @@ def _main_inner() -> None:
|
||||
if browser_should_run[0] and not browser.is_running():
|
||||
cfg = _browser_cfg()
|
||||
now = time.monotonic()
|
||||
max_retries = max(0, int(svc.settings.max_browser_relaunches))
|
||||
if cfg.auto_relaunch and (now - browser_last_launch_ts[0]) >= 3.0:
|
||||
if _launch_browser_with_cfg(cfg):
|
||||
if browser_relaunch_count[0] >= max_retries:
|
||||
browser_should_run[0] = False
|
||||
_log(
|
||||
f"Browser auto-relaunch disabled after "
|
||||
f"{browser_relaunch_count[0]} attempts — fix the profile "
|
||||
"or restart manually."
|
||||
)
|
||||
elif _launch_browser_with_cfg(cfg):
|
||||
browser_last_launch_ts[0] = now
|
||||
browser_relaunch_count[0] += 1
|
||||
root.after(80, _pump)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,66 +161,10 @@ def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DnsLeakResult:
|
||||
resolver: str
|
||||
ip_via_proxy: str | None
|
||||
ip_direct: str | None
|
||||
leaked: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def check_dns_leak(
|
||||
proxy_url: str,
|
||||
timeout_seconds: float = 8.0,
|
||||
) -> list[DnsLeakResult]:
|
||||
"""
|
||||
Detect DNS leaks: compare hostname resolution seen through proxy vs direct.
|
||||
A mismatch means DNS is escaping the tunnel.
|
||||
"""
|
||||
test_hosts = [
|
||||
("Cloudflare (1.1.1.1)", "one.one.one.one"),
|
||||
("Google (8.8.8.8)", "dns.google"),
|
||||
("OpenDNS", "resolver1.opendns.com"),
|
||||
]
|
||||
results: list[DnsLeakResult] = []
|
||||
timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds))
|
||||
|
||||
for label, host in test_hosts:
|
||||
# Get IP via direct system DNS
|
||||
direct_ip = _dns_resolve_via_system(host)
|
||||
|
||||
# Get IP as seen from the proxy path (via http://dns-endpoint)
|
||||
proxy_ip: str | None = None
|
||||
try:
|
||||
with httpx.Client(proxy=proxy_url, timeout=timeout, verify=False, follow_redirects=True) as c:
|
||||
r = c.get(f"https://{host}/")
|
||||
proxy_ip = str(r.headers.get("x-real-ip") or "")
|
||||
if not proxy_ip:
|
||||
# fall back: grab connected IP from response
|
||||
proxy_ip = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Simple leak heuristic: if direct resolution works but proxy connection fails, possible leak path
|
||||
leaked = bool(direct_ip and not proxy_ip)
|
||||
if leaked:
|
||||
detail = f"DNS resolved directly to {direct_ip} but proxy could not reach it — possible bypass"
|
||||
elif not direct_ip:
|
||||
detail = "Could not resolve directly"
|
||||
leaked = False
|
||||
else:
|
||||
detail = f"Direct: {direct_ip}"
|
||||
|
||||
results.append(DnsLeakResult(
|
||||
resolver=label,
|
||||
ip_via_proxy=proxy_ip,
|
||||
ip_direct=direct_ip,
|
||||
leaked=leaked,
|
||||
detail=detail,
|
||||
))
|
||||
|
||||
return results
|
||||
# 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(
|
||||
|
||||
@@ -135,6 +135,59 @@ def _firefox_is_running_on_system() -> bool:
|
||||
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
|
||||
@@ -145,16 +198,15 @@ class BrowserSession:
|
||||
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 but also
|
||||
# fall back to checking the system process list so the GUI doesn't
|
||||
# falsely report "stopped".
|
||||
# browser child processes carry on. Poll the parent process first.
|
||||
if self._proc.poll() is None:
|
||||
return True
|
||||
# Parent has exited — check if any firefox.exe is still alive AND we
|
||||
# launched within the last 5 minutes (to avoid false positives from
|
||||
# unrelated browser sessions).
|
||||
age = time.monotonic() - self._launched_at
|
||||
return age < 300 and _firefox_is_running_on_system()
|
||||
# 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:
|
||||
|
||||
@@ -200,6 +200,8 @@ class Settings:
|
||||
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
||||
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
||||
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
|
||||
validate_pinned_on_start: bool = True # quick TCP probe of each pinned hop before chaining
|
||||
max_browser_relaunches: int = 5 # cap auto-relaunch attempts so a broken profile doesn't loop
|
||||
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
|
||||
manual_exit_proxy: str = ""
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ 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:
|
||||
@@ -58,10 +63,55 @@ def _verify_zip_sha256(data: bytes, expected: str) -> None:
|
||||
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 ensure_gost(target: Path | None = None) -> Path:
|
||||
exe = target or gost_exe_path()
|
||||
if exe.is_file() and exe.stat().st_size > 10_000:
|
||||
return exe
|
||||
# Re-verify the on-disk binary against the sidecar pin so a tampered
|
||||
# exe cannot persist across launches.
|
||||
pinned = _read_pinned_exe_hash(exe)
|
||||
if pinned:
|
||||
actual = _hash_file(exe)
|
||||
if actual == pinned:
|
||||
return exe
|
||||
log.warning(
|
||||
"gost.exe SHA256 mismatch on reuse — re-downloading.\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
|
||||
exe.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
|
||||
_add_defender_exclusion(exe)
|
||||
@@ -82,6 +132,8 @@ def ensure_gost(target: Path | None = None) -> Path:
|
||||
shutil.copyfileobj(src, dst)
|
||||
# Add exclusion again after write in case Defender scanned during extraction
|
||||
_add_defender_exclusion(exe)
|
||||
# Pin the freshly-extracted exe so subsequent launches can re-verify it.
|
||||
_write_pinned_exe_hash(exe, _hash_file(exe))
|
||||
log.info("GOST installed at %s", exe)
|
||||
return exe
|
||||
|
||||
|
||||
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)
|
||||
@@ -70,6 +70,22 @@ _FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
|
||||
from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
|
||||
|
||||
|
||||
async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool:
|
||||
"""Open and close a TCP socket; True if the handshake completes within *timeout*."""
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=timeout,
|
||||
)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
class ChainService:
|
||||
"""Background rotating proxy chain using GOST."""
|
||||
|
||||
@@ -400,6 +416,26 @@ class ChainService:
|
||||
})
|
||||
if self._settings.use_pinned_chain and self._settings.pinned_chain:
|
||||
chain = list(self._settings.pinned_chain)
|
||||
|
||||
# Optional per-hop TCP probe so a known-dead pinned chain
|
||||
# doesn't loop forever on the 10 s retry below.
|
||||
if self._settings.validate_pinned_on_start:
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
dead = []
|
||||
for hop in chain:
|
||||
try:
|
||||
p = _urlparse(hop if "://" in hop else "http://" + hop)
|
||||
if p.hostname and p.port:
|
||||
if not await _probe_tcp(p.hostname, int(p.port), 6.0):
|
||||
dead.append(hop)
|
||||
except Exception: # noqa: BLE001
|
||||
dead.append(hop)
|
||||
if dead:
|
||||
self._notify({"type": "log", "text": (
|
||||
f"Pinned chain: {len(dead)}/{len(chain)} hop(s) failed TCP "
|
||||
f"probe — proceeding anyway (will retry on failure)."
|
||||
)})
|
||||
|
||||
rotation_num += 1
|
||||
self._notify({"type": "rotation", "n": rotation_num})
|
||||
log.info("Pinned chain run: %d hops", len(chain))
|
||||
|
||||
@@ -282,21 +282,30 @@ def resolve_signup_url(draft: SignupDraft) -> str:
|
||||
|
||||
|
||||
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"))
|
||||
return SignupDraft(**{k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__})
|
||||
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:
|
||||
draft_path().write_text(json.dumps(asdict(draft), indent=2), encoding="utf-8")
|
||||
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 []
|
||||
@@ -312,6 +321,9 @@ def load_accounts() -> list[AccountRecord]:
|
||||
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):
|
||||
@@ -319,8 +331,17 @@ def load_accounts() -> list[AccountRecord]:
|
||||
|
||||
|
||||
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([asdict(r) for r in rows], indent=2),
|
||||
json.dumps(serialized, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user