Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,19 @@ jobs:
- name: Smoke test the built application
run: python packaging/smoke.py "dist/Proxy Workbench.app/Contents/MacOS/Proxy Workbench"

# The smoke test only talks to local mocks. A frozen build can still fail
# every real HTTPS download (3.0.0 did: no trusted certificates), so the
# built app has to collect the quick source set from the internet.
- name: Collect real sources over HTTPS with the built application
run: |
app="dist/Proxy Workbench.app/Contents/MacOS/Proxy Workbench"
data="$RUNNER_TEMP/pw-https-check"
"$app" source set quick --data "$data"
"$app" collect --data "$data" | tee "$RUNNER_TEMP/collect.log"
count=$(sed -n 's/^Unique candidates in the database: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/collect.log")
echo "collected: ${count:-0}"
test "${count:-0}" -gt 0

- name: Report what the build recorded
run: |
python - <<'PY'
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ jobs:
- name: Smoke test the console CLI executable
run: python packaging/smoke.py dist/proxy-workbench-cli.exe

# Local mocks cannot show whether real HTTPS downloads verify in the
# frozen build, so collect the quick source set from the internet.
- name: Collect real sources over HTTPS with the built CLI
shell: bash
run: |
data="$RUNNER_TEMP/pw-https-check"
dist/proxy-workbench-cli.exe source set quick --data "$data"
dist/proxy-workbench-cli.exe collect --data "$data" | tee "$RUNNER_TEMP/collect.log"
count=$(sed -n 's/^Unique candidates in the database: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/collect.log")
echo "collected: ${count:-0}"
test "${count:-0}" -gt 0

# The GUI executable is windowed, so it has nowhere to print: what it
# prints is the log the build already produced.
- name: Report what the build recorded
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

The format follows Keep a Changelog and semantic versioning.

## [3.0.1] — 2026-09-27

### Fixed

- **Checks run up to 16× faster on large lists.** Every scan was held at 64 checks in flight whatever the worker setting said (the memory reserved for response bodies had a fixed 64 MiB budget); the budget now grows with the workers up to 1 GiB. Ports of one IP no longer queue up together either: the first port of every host is checked first and further ports follow host by host. A full check of 663,000 collected addresses went from 25 to about 390 addresses per second with 512 workers.
- **macOS app: collecting and HTTPS checks work again.** The 3.0.0 macOS builds looked for trusted certificates in a folder of the build machine, so every HTTPS source failed with a connection error and no proxies could be collected. Every HTTPS connection (sources, HTTPS checks, the gateway's HTTPS upstreams) now also trusts the certificate bundle shipped inside the app, while the system store is still used.
- **The rotating proxy is far more reliable with flaky free proxies.** A proxy that does not answer within 2 seconds no longer blocks the request: the next proxy is tried alongside it and the first working tunnel wins (up to 4 proxies; a failed request now gives up after about 14 s instead of 24 s). When every proxy in a small pool was resting after failures, the gateway answered 502 instantly for five minutes; it now falls back to the proxy that is due back first. On 11 real public proxies right after a restart: 18 of 20 requests succeeded instead of 6 of 10.
- Simultaneous gateway connect outcomes now record failed proxies before serving the winner. Proxies still connecting after another wins are marked slow, so they stop delaying each request. A failover session is pinned to the tunnel that actually won, and strict sessions keep their single-proxy rule.
- The results view looks up source provenance only for its selected profile, avoiding a scan of every collected candidate on each request.
- **“This data folder is already used by another run” when starting a check.** The desktop app's background job runner and scheduler hold the data lock for a moment every second, and a check started from the interface gave up on the first try. A run now waits up to 10 seconds for such a short hold; a folder that stays busy is still refused.
- The interface server reads or closes the body of a request it refuses, so a refused request no longer shows up as a reset connection on Windows or as a garbled next request.

## [3.0.0] — 2026-09-27

The biggest release so far: a new interface, a source catalog of 150 lists, a desktop app that lives in the menu bar, persistent proxy pools with schedules, API keys, diagnostics that explain an empty result, and backups you can preview before restoring. Existing data folders are upgraded in place, with an automatic copy taken first.
Expand Down
2 changes: 1 addition & 1 deletion packaging/windows-installer.iss
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
; Per-user installer for the Windows desktop build.
;
; iscc /DProductVersion=3.0.0 /DOutDir=C:\path\to\dist /DSourceDir=C:\path\to\dist packaging\windows-installer.iss
; iscc /DProductVersion=3.0.1 /DOutDir=C:\path\to\dist /DSourceDir=C:\path\to\dist packaging\windows-installer.iss
;
; PrivilegesRequired=lowest is the whole point: the app writes to per-user
; folders, so it never needs an administrator, and it never installs anything
Expand Down
2 changes: 1 addition & 1 deletion proxy_workbench/branding.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

PRODUCT_NAME = "Proxy Workbench"
PRODUCT_ID = "ProxyWorkbench"
PRODUCT_VERSION = "3.0.0"
PRODUCT_VERSION = "3.0.1"
DEFAULT_REQUEST_PROFILE = "workbench"
PROJECT_URL = "https://github.com/DavidVoitenko/proxy-workbench"
# Newest built-in source list, fetched only when the user asks for it. This is
Expand Down
174 changes: 136 additions & 38 deletions proxy_workbench/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
from collections import OrderedDict
from pathlib import Path

from . import geoip, reputation, secrets as secretstore, socks4
from . import geoip, reputation, secrets as secretstore, socks4, tls
from .api import Exports, is_loopback, select
from .i18n import tr

Expand All @@ -76,7 +76,7 @@
# itself; a target that refused must never rest a working upstream.
HEALTH_GOOD = frozenset({'response', 'tunnel_bytes'})
HEALTH_TARGET = frozenset({'upstream_unavailable', 'no_response'})
HEALTH_FAULT = frozenset({'handshake_failed', 'upstream_refused', 'closed_empty'})
HEALTH_FAULT = frozenset({'handshake_failed', 'slow_connect', 'upstream_refused', 'closed_empty'})
HEALTH_OUTCOMES = HEALTH_GOOD | HEALTH_TARGET | HEALTH_FAULT
#: Half-life of a gateway health observation, seconds.
HEALTH_DECAY = 300.0
Expand Down Expand Up @@ -750,17 +750,19 @@ def pick(self, exclude=(), request=None, session=None, sticky=None, binding=None
"""Choose a proxy without taking a slot. Prefer :meth:`reserve` in a gateway."""
return self._pick(exclude, request, session, sticky, binding, reserve=False)

def reserve(self, request=None, exclude=(), session=None, sticky=None, binding=None):
def reserve(self, request=None, exclude=(), session=None, sticky=None, binding=None,
commit_session=True):
"""Pick a proxy and take its concurrency slot in one indivisible step.

Doing both together is what keeps parallel connects under
``max_per_proxy``: there is no await between the check and the
reservation, so two clients can never observe the same free slot.
The caller owns the returned :class:`Lease`.
"""
return self._pick(exclude, request, session, sticky, binding, reserve=True)
return self._pick(exclude, request, session, sticky, binding, reserve=True,
commit_session=commit_session)

def _pick(self, exclude, request, session, sticky, binding, reserve):
def _pick(self, exclude, request, session, sticky, binding, reserve, commit_session=True):
now = time.monotonic()
sticky = self._sticky(binding, sticky)
with self.lock:
Expand All @@ -771,23 +773,45 @@ def _pick(self, exclude, request, session, sticky, binding, reserve):
if pinned and pinned in candidates:
if reserve:
self._take(pinned)
self.sessions[session] = (pinned, now + self.session_ttl)
if commit_session:
self.sessions[session] = (pinned, now + self.session_ttl)
return Lease(self, pinned)
return pinned
if pinned and sticky == 'strict':
# A strict session never silently changes its address: no
# free slot for the proxy it is bound to means a refusal.
return None
if not candidates:
return None
choice = self._choose(candidates, self._strategy(binding), now)
if session:
# Every matching proxy is resting. Refusing outright turned a
# small pool of flaky public proxies into a gateway that answered
# 502 instantly for five minutes; the one whose rest ends first
# is still a better answer than none.
candidates = self._resting_fallback(request, now, binding, exclude)
if not candidates:
return None
choice = candidates[0]
else:
choice = self._choose(candidates, self._strategy(binding), now)
if session and commit_session:
self.sessions[session] = (choice, now + self.session_ttl)
if not reserve:
return choice
self._take(choice)
return Lease(self, choice)

def bind_session(self, session, proxy):
"""Pin a session only after its upstream tunnel has won the race."""
with self.lock:
self.sessions[session] = (proxy, time.monotonic() + self.session_ttl)

def _resting_fallback(self, request, now, binding, exclude):
"""Resting proxies that could serve this request, soonest back first."""
limit = self._limit(binding)
resting = [proxy for proxy in self.matching(request, binding)
if proxy not in exclude and self.resting.get(proxy, 0) > now and self.allowed(proxy)
and (not limit or self.active.get(proxy, 0) < limit)]
return sorted(resting, key=lambda proxy: self.resting[proxy])

def _take(self, proxy):
self.active[proxy] = self.active.get(proxy, 0) + 1

Expand Down Expand Up @@ -1019,7 +1043,7 @@ async def open_tunnel(proxy, host, port, forward=False, ssl_context=None, creden
proxy_host, _, proxy_port = address.rpartition(':')
proxy_host = proxy_host.strip('[]')
if scheme == 'https':
context = ssl_context or ssl.create_default_context()
context = ssl_context or tls.default_context()
reader, writer = await asyncio.open_connection(proxy_host, int(proxy_port), limit=MAX_HEAD,
ssl=context, server_hostname=proxy_host)
else:
Expand Down Expand Up @@ -1190,7 +1214,7 @@ def credential_state(source):


class Gateway:
def __init__(self, pool, token=None, attempts=3, connect_timeout=8, idle_timeout=300,
def __init__(self, pool, token=None, attempts=4, connect_timeout=8, idle_timeout=300,
allow_local_without_auth=False, *, handshake_timeout=30, response_timeout=15,
max_clients=MAX_CLIENTS, max_session=0, bind=None, token_origin=None,
drain_timeout=2.0, ssl_context=None, refresh_interval=2.0,
Expand All @@ -1200,6 +1224,8 @@ def __init__(self, pool, token=None, attempts=3, connect_timeout=8, idle_timeout
self.token_origin = token_origin or ('explicit' if token else 'none')
self.attempts = max(1, int(attempts))
self.connect_timeout = connect_timeout
# Seconds before the next proxy joins a connect attempt that has not answered.
self.stagger = min(2.0, float(connect_timeout))
self.idle_timeout = idle_timeout
self.allow_local_without_auth = allow_local_without_auth
self.handshake_timeout = handshake_timeout
Expand Down Expand Up @@ -1307,38 +1333,110 @@ async def connect(self, host, port, forward=False, request=None, session=None, b

The returned lease owns the concurrency slot; the caller must release it
(or use ``with``) whatever happens to the stream.

Attempts are staggered rather than strictly one after another: when a
proxy has not answered within ``stagger`` seconds, the next one starts
alongside it and the first tunnel wins. One after another, three dead
proxies cost a client three full connect timeouts before a 502. A
strict sticky session keeps the sequential order, because it may only
fall through when its own proxy failed.
"""
tried = set()
for attempt in range(self.attempts):
pending = {}
staggered = set()
failures = 0
won = False
strict = bool(session and self.pool._sticky(binding, sticky) == 'strict')

def start_next():
lease = self.pool.reserve(request=request, exclude=tried, session=session,
sticky=sticky, binding=binding)
sticky=sticky, binding=binding, commit_session=strict)
if lease is None:
break
proxy = lease.proxy
tried.add(proxy)
self.pool.stats['retries'] += attempt > 0
started = time.monotonic()
try:
stream, credentials = await asyncio.wait_for(
self._tunnel(proxy, host, port, forward), self.connect_timeout)
except (OSError, UpstreamError, ValueError, UnicodeError) as exc:
lease.release()
self.pool.outcome(proxy, 'handshake_failed', detail=UNUSABLE)
if session and sticky == 'strict':
return False
tried.add(lease.proxy)
self.pool.stats['retries'] += len(tried) > 1
task = asyncio.ensure_future(asyncio.wait_for(
self._tunnel(lease.proxy, host, port, forward), self.connect_timeout))
pending[task] = (lease, time.monotonic())
return True

try:
if not start_next():
self.pool.stats['failed'] += 1
raise UpstreamError('NO_PROXIES')
while pending:
can_add = not strict and len(tried) < self.attempts
done, _ = await asyncio.wait(list(pending), timeout=self.stagger if can_add else None,
return_when=asyncio.FIRST_COMPLETED)
if not done and can_add:
# The wait itself establishes that these attempts have
# missed the policy deadline. Comparing clocks again in
# cleanup can lose a few milliseconds on Windows.
staggered.update(pending)
winner = None
for task in list(pending):
if task not in done:
continue
lease, started = pending.pop(task)
try:
stream, credentials = task.result()
except (OSError, UpstreamError, ValueError, UnicodeError):
lease.release()
self.pool.outcome(lease.proxy, 'handshake_failed', detail=UNUSABLE)
failures += 1
continue
if winner is None:
winner = (lease, stream, credentials, started)
else:
# Two tunnels can finish in one event-loop turn. Only
# one may keep its reservation and open stream.
stream[1].close()
lease.release()
if winner is not None:
lease, stream, credentials, started = winner
# The plain-HTTP path still has to write the credential onto the
# wire, so it rides on the lease and is dropped the moment the
# request is written.
lease.credentials = credentials
self.pool.connected(lease.proxy, (time.monotonic() - started) * 1000)
if session:
self.pool.bind_session(session, lease.proxy)
won = True
return lease, stream
if strict and failures:
break
continue
except BaseException:
# Cancellation and timeout both belong here: the slot must not leak.
# Either the stagger ran out or everything that finished failed.
if not strict and len(tried) < self.attempts:
start_next()
self.pool.stats['failed'] += 1
raise UpstreamError('NO_WORKING_PROXY')
finally:
# A loser that was still connecting after the stagger has proved
# too slow for this gateway. Count it, or it gets picked again on
# every request and delays every other connection forever.
for task, (lease, _) in pending.items():
unfinished = not task.done()
if unfinished:
task.cancel()
lease.release()
raise
# The plain-HTTP path still has to write the credential onto the
# wire, so it rides on the lease and is dropped the moment the
# request is written.
lease.credentials = credentials
self.pool.connected(proxy, (time.monotonic() - started) * 1000)
return lease, stream
self.pool.stats['failed'] += 1
raise UpstreamError('NO_WORKING_PROXY' if tried else 'NO_PROXIES')
if won and unfinished and task in staggered:
self.pool.outcome(lease.proxy, 'slow_connect', detail='slow_connect')
for task in pending:
with contextlib.suppress(BaseException):
await task
for task, (lease, _) in pending.items():
if task.cancelled():
continue
try:
stream = task.result()[0]
except (OSError, UpstreamError, ValueError, UnicodeError):
if won:
self.pool.outcome(lease.proxy, 'handshake_failed', detail=UNUSABLE)
except BaseException:
pass
else:
with contextlib.suppress(Exception):
stream[1].close()

# --- authentication ------------------------------------------------------

Expand Down Expand Up @@ -1866,7 +1964,7 @@ async def start(data, host=DEFAULT_HOST, port=DEFAULT_PORT, token=None, filters=
on_deny='keep', bindings=None, default_binding=None, binding=None,
credentials=None,
handshake_timeout=30, max_clients=MAX_CLIENTS,
max_session=0, cache_limit=CACHE_LIMIT, attempts=3, connect_timeout=8,
max_session=0, cache_limit=CACHE_LIMIT, attempts=4, connect_timeout=8,
idle_timeout=300, refresh_interval=2.0, response_timeout=15,
max_failures=2, cooldown=300):
"""Listen for proxy clients and spread their connections over the export.
Expand Down
Loading
Loading