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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
.github
.venv
data
!proxy_workbench/data/
!proxy_workbench/data/*.json
docs
tests
packaging
Expand Down
2 changes: 1 addition & 1 deletion .github/repo-settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"description": "Free proxies that actually work for YOUR sites: collects 55 public lists, checks HTTP/HTTPS/SOCKS4/SOCKS5 against your services, rates anonymity, filters by country and blacklists, then serves them as a rotating proxy, API, PAC and Clash config. Local GUI + CLI + Docker.",
"description": "Finds public proxies that work on the sites you need. Browse a 150-source catalog, collect supported feeds, check against your services, and use ready lists, a rotating gateway and an API. macOS/Windows app, CLI, Docker, 12 languages.",
"homepage_from_pages": true,
"has_discussions": true,
"has_issues": true,
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ jobs:
docker run --rm proxy-workbench:ci --help > /dev/null
docker run --rm proxy-workbench:ci clear-data --yes
docker run --rm --entrypoint python proxy-workbench:ci -c "from proxy_workbench import source_catalog; assert source_catalog.load_bundled()['sources']"
docker run --rm proxy-workbench:ci preset list --json > /dev/null

# The .app users install. This job builds it on a real macOS runner,
# starts it, and checks the page it serves, its per-user data folder, a second
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

The format follows Keep a Changelog and semantic versioning.

## [3.0.2] — 2026-09-27

### Fixed

- Source retries now publish candidates only from a completed response. Byte and line limits keep valid following addresses, and the scan enforces each target's request cap even when one probe makes several requests.
- The API rejects invalid numbers and cursors cleanly, executes concurrent retries with the same idempotency key once, pages scoped jobs and events correctly, and releases SSE slots when clients leave.
- API key forms submit the selected permissions. The import wizard offers every CSV/JSON column, accepts the first or duplicate column, and discards previews after the input changes. Switched tabs remain visible when a browser pauses animations.
- Docker includes the service preset catalog. Its Compose gateway can start on the configured network bind, and the CLI shows a generated password or usable authenticated examples without printing a supplied secret.

## [3.0.1] — 2026-09-27

### Fixed
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ COPY requirements.txt ./
RUN python -m pip install --requirement requirements.txt

COPY proxy_workbench/*.py proxy_workbench/*.json ./proxy_workbench/
COPY proxy_workbench/data/ ./proxy_workbench/data/
COPY proxytool.py service.example.json ./
RUN useradd --create-home --uid 10001 workbench \
&& mkdir -p /app/data \
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Pick one way to install:
| **Windows app** | Download `proxy-workbench-…-windows-x64-setup.exe` from the [latest release](https://github.com/DavidVoitenko/proxy-workbench/releases/latest) and run it; there is a portable `.zip` too, and a separate `proxy-workbench-cli.exe` for the command line | nothing else |
| **pipx** (Windows, macOS, Linux) | `pipx install git+https://github.com/DavidVoitenko/proxy-workbench` then `proxy-workbench` | Python 3.11+ and [pipx](https://pypa.io/pipx/) |
| **Source folder** | Download the code (**Code → Download ZIP** or `git clone`), then double-click `Start.bat` (Windows) / `Start.command` (macOS) or run `./run.sh` (Linux) | Python 3.11+ |
| **Docker** | `docker compose up -d` with the bundled [`compose.yml`](compose.yml) (checker + API + rotating proxy) | Docker |
| **Docker** | Set `PROXY_WORKBENCH_API_TOKEN`, then run `docker compose up -d` with the bundled [`compose.yml`](compose.yml) (checker + API + rotating proxy) | Docker |

`proxy-workbench` without arguments starts the application: the interface opens in your browser, and on macOS a menu bar item shows what is happening and offers pause, start, “start at login” and quit. Starting it a second time reaches the one that is already running instead of opening a rival. `proxy-workbench run …` and the other commands below work the same way as `./run.sh …`.

Expand Down Expand Up @@ -288,7 +288,7 @@ curl -x socks5h://127.0.0.1:8899 https://example.org/
- If a proxy fails, the same connection is retried through another one (up to 3). A proxy that fails twice rests for 5 minutes.
- Plain `http://` requests reach HTTP proxies directly, because many of them allow CONNECT only to port 443.

On a server, start it with `./run.sh gateway` and narrow the pool with the usual filters, for example `gateway --protocol socks5 --country DE --max-latency 1500`. Binding to a network address (`--host 0.0.0.0`) requires a password: `--gateway-token <secret>`, or the `PROXY_WORKBENCH_GATEWAY_TOKEN` variable; when it is not given the gateway makes one and prints it. Clients then log in with any user name and that password, over HTTP Basic or SOCKS5 user/password.
On a server, start it with `./run.sh gateway` and narrow the pool with the usual filters, for example `gateway --protocol socks5 --country DE --max-latency 1500`. Binding to a network address requires `--host 0.0.0.0 --lan` and a password: `--gateway-token <secret>`, or the `PROXY_WORKBENCH_GATEWAY_TOKEN` variable; when it is not given the gateway makes one and prints it. Clients then log in with any user name and that password, over HTTP Basic or SOCKS5 user/password.

**The gateway password is not the API token.** They are separate identities on purpose: whoever knows the password you handed to a phone must not be able to read the published snapshot, and a leaked API token must not be a working proxy. Pass `--api-token` to `serve` and `--gateway-token` to `gateway`; `compose.yml` shows both variables.

Expand Down Expand Up @@ -380,11 +380,11 @@ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/data:/app/data" \
Serve fresh proxies to other containers: one container re-checks, the other answers API requests from the same data folder.

```sh
docker run -d --name pw-check -v "$PWD/data:/app/data" proxy-workbench run --want 50 --watch 30
docker run -d --name pw-api -p 127.0.0.1:8765:8765 -e PROXY_WORKBENCH_API_TOKEN=change-me \
docker run -d --name pw-check --user "$(id -u):$(id -g)" -v "$PWD/data:/app/data" proxy-workbench run --want 50 --watch 30
docker run -d --name pw-api --user "$(id -u):$(id -g)" -p 127.0.0.1:8765:8765 -e PROXY_WORKBENCH_API_TOKEN=change-me \
-v "$PWD/data:/app/data" proxy-workbench serve --host 0.0.0.0
docker run -d --name pw-gateway -p 127.0.0.1:8899:8899 -e PROXY_WORKBENCH_API_TOKEN=change-me \
-v "$PWD/data:/app/data" proxy-workbench gateway --host 0.0.0.0
docker run -d --name pw-gateway --user "$(id -u):$(id -g)" -p 127.0.0.1:8899:8899 -e PROXY_WORKBENCH_GATEWAY_TOKEN=another-secret \
-v "$PWD/data:/app/data" proxy-workbench gateway --host 0.0.0.0 --lan
```

Every release also publishes a ready image to the GitHub Container Registry. It appears under **Packages** in the repository sidebar as `ghcr.io/<owner>/proxy-workbench:<version>` and `:latest`. Results land in the mounted `data/` folder exactly as with a local install.
Expand Down
12 changes: 6 additions & 6 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
| **Программа для Windows** | Скачайте `proxy-workbench-…-windows-x64-setup.exe` из [последнего релиза](https://github.com/DavidVoitenko/proxy-workbench/releases/latest) и запустите установщик; есть portable-архив `.zip` и отдельный `proxy-workbench-cli.exe` для командной строки | больше ничего |
| **pipx** (Windows, macOS, Linux) | `pipx install git+https://github.com/DavidVoitenko/proxy-workbench`, затем `proxy-workbench` | Python 3.11+ и [pipx](https://pypa.io/pipx/) |
| **Папка с кодом** | Скачайте код (**Code → Download ZIP** или `git clone`) и запустите, как в таблице ниже | Python 3.11+ |
| **Docker** | `docker compose up -d` с готовым [`compose.yml`](compose.yml): проверка + API + ротирующий прокси | Docker |
| **Docker** | Задайте `PROXY_WORKBENCH_API_TOKEN`, затем запустите `docker compose up -d` с готовым [`compose.yml`](compose.yml): проверка + API + ротирующий прокси | Docker |

`proxy-workbench` без аргументов запускает приложение: интерфейс открывается в браузере, а на macOS в меню-баре появляется значок с состоянием и пунктами «пауза», «запуск проверки», «запускать при входе» и «выход». Повторный запуск обращается к уже работающему экземпляру, а не поднимает вторую копию. `proxy-workbench run …` и остальные команды работают так же, как `./run.sh …`.

Expand Down Expand Up @@ -379,7 +379,7 @@ curl -x socks5h://127.0.0.1:8899 https://example.org/
- Если прокси не ответил, то же соединение повторяется через другой (до 3 раз). Прокси, который ошибся дважды, отдыхает 5 минут.
- Обычные `http://`-запросы идут в HTTP-прокси напрямую, потому что многие из них разрешают CONNECT только на порт 443.

На сервере шлюз запускает `./run.sh gateway`; пул сужается обычными фильтрами, например `gateway --protocol socks5 --country DE --max-latency 1500`. Для сетевого адреса (`--host 0.0.0.0`) нужен пароль: `--gateway-token <секрет>` или переменная `PROXY_WORKBENCH_GATEWAY_TOKEN`; если он не задан, шлюз придумывает свой и печатает его. Клиенты входят с любым именем и этим паролем через HTTP Basic или логин/пароль SOCKS5.
На сервере шлюз запускает `./run.sh gateway`; пул сужается обычными фильтрами, например `gateway --protocol socks5 --country DE --max-latency 1500`. Для сетевого адреса нужны `--host 0.0.0.0 --lan` и пароль: `--gateway-token <секрет>` или переменная `PROXY_WORKBENCH_GATEWAY_TOKEN`; если он не задан, шлюз придумывает свой и печатает его. Клиенты входят с любым именем и этим паролем через HTTP Basic или логин/пароль SOCKS5.

**Пароль шлюза — не токен API.** Это разные идентичности намеренно: тот, кому вы дали пароль для телефона в сети, не должен читать опубликованный снапшот, а утёкший токен API не должен работать как прокси. `serve` получает `--api-token`, `gateway` — `--gateway-token`; в `compose.yml` показаны обе переменные.

Expand Down Expand Up @@ -471,11 +471,11 @@ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/data:/app/data" \
Раздавать свежие прокси другим контейнерам: один контейнер перепроверяет, второй отвечает на запросы API из той же папки данных.

```sh
docker run -d --name pw-check -v "$PWD/data:/app/data" proxy-workbench run --want 50 --watch 30
docker run -d --name pw-api -p 127.0.0.1:8765:8765 -e PROXY_WORKBENCH_API_TOKEN=change-me \
docker run -d --name pw-check --user "$(id -u):$(id -g)" -v "$PWD/data:/app/data" proxy-workbench run --want 50 --watch 30
docker run -d --name pw-api --user "$(id -u):$(id -g)" -p 127.0.0.1:8765:8765 -e PROXY_WORKBENCH_API_TOKEN=change-me \
-v "$PWD/data:/app/data" proxy-workbench serve --host 0.0.0.0
docker run -d --name pw-gateway -p 127.0.0.1:8899:8899 -e PROXY_WORKBENCH_API_TOKEN=change-me \
-v "$PWD/data:/app/data" proxy-workbench gateway --host 0.0.0.0
docker run -d --name pw-gateway --user "$(id -u):$(id -g)" -p 127.0.0.1:8899:8899 -e PROXY_WORKBENCH_GATEWAY_TOKEN=another-secret \
-v "$PWD/data:/app/data" proxy-workbench gateway --host 0.0.0.0 --lan
```

Каждый релиз также публикует готовый образ в GitHub Container Registry. Он появляется в разделе **Packages** на странице репозитория как `ghcr.io/<owner>/proxy-workbench:<версия>` и `:latest`. Результаты сохраняются в подключённую папку `data/`, как при обычной установке.
Expand Down
2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ services:

gateway:
<<: *workbench
command: ["gateway", "--host", "0.0.0.0"]
command: ["gateway", "--host", "0.0.0.0", "--lan"]
environment:
# Not the API token: the gateway password is a separate identity. Unset is
# fine -- the gateway then generates one and prints it.
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.1 /DOutDir=C:\path\to\dist /DSourceDir=C:\path\to\dist packaging\windows-installer.iss
; iscc /DProductVersion=3.0.2 /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
42 changes: 28 additions & 14 deletions proxy_workbench/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1545,14 +1545,23 @@ def _job_store(self):

def _op_jobs_list(self, call):
store, conn = self._job_store()
limit = min(int(call.query.get('limit') or 50), 500)
offset = self._offset(call)
try:
items = [_job_dict(job)
for job in store.jobs(limit=min(int(call.query.get('limit') or 50), 500))] \
if store is not None else []
allowed = _scope_values(call.principal, 'collections')
scoped = sorted(allowed) if allowed is not None else None
if store is None:
items, total = [], 0
else:
jobs = store.jobs(collection_ids=scoped, limit=limit, offset=offset)
items = [_job_dict(job) for job in jobs]
total = store.count_jobs(collection_ids=scoped)
finally:
_close(conn)
return {'items': self._guard_objects(items, call, 'id', 'jobs'),
'stream_id': 'jobs', 'next_seq': None}
items = self._guard_objects(items, call, 'id', 'jobs')
return {'items': items, 'stream_id': 'jobs', 'total': total,
'cursor_seq': offset + len(items) if items else None,
'next_seq': offset + len(items) + 1 if offset + len(items) < total else None}

def _op_jobs_get(self, call):
store, conn = self._job_store()
Expand Down Expand Up @@ -2061,19 +2070,21 @@ def _op_events_system(self, call):
conn = self.connection()
items = []
try:
rows = conn.execute(
'SELECT e.rowid AS rid, e.job_id, e.seq, e.at, e.type, e.code, e.data_json, '
'j.collection_id FROM job_event e LEFT JOIN job j ON j.id = e.job_id '
'WHERE e.rowid > ? ORDER BY e.rowid LIMIT ?',
(after, limit if allowed is None else limit * 20)).fetchall() \
if conn is not None else []
sql = ('SELECT e.rowid AS rid, e.job_id, e.seq, e.at, e.type, e.code, '
'e.data_json, j.collection_id FROM job_event e '
'LEFT JOIN job j ON j.id = e.job_id WHERE e.rowid > ?')
params = [after]
if allowed is not None:
sql += f' AND j.collection_id IN ({",".join("?" * len(allowed))})'
params.extend(sorted(allowed))
sql += ' ORDER BY e.rowid LIMIT ?'
params.append(limit)
rows = conn.execute(sql, params).fetchall() if conn is not None else []
except sqlite3.Error:
rows = []
finally:
_close(conn)
for row in rows:
if allowed is not None and row['collection_id'] not in allowed:
continue
try:
data = json.loads(row['data_json'] or '{}')
except (TypeError, ValueError):
Expand Down Expand Up @@ -4503,13 +4514,16 @@ def _control(self):
self.send_header('Connection', 'close')
self.end_headers()
if self.command == 'HEAD':
response.stream.close()
return
try:
for frame in response.stream:
self.wfile.write(frame.encode('utf-8') if isinstance(frame, str) else frame)
self.wfile.flush()
except OSError:
pass # the subscriber went away; the stream releases its slot itself
pass # the subscriber went away
finally:
response.stream.close()

def do_GET(self):
url = urlsplit(self.path)
Expand Down
Loading
Loading