Compare commits

...

30 Commits

Author SHA1 Message Date
Flowseal be485a3851 version bump 2026-09-04 17:49:40 +03:00
Flowseal f811a10918 don't close update dialog on release page open 2026-09-04 17:36:15 +03:00
Flowseal 55ec6a91df remove topmost 2026-09-04 17:35:41 +03:00
Flowseal b23c8f6122 #1213 fixes 2026-09-04 17:22:30 +03:00
Konukhov Yaroslav d36bb7df71 feat: add manual update triggers to settings and tray menu (#1213) 2026-09-04 17:13:56 +03:00
Flowseal bd4f3d080c todo text correction 2026-09-03 20:20:41 +03:00
Flowseal 492d999248 closes #1232 2026-09-03 20:17:46 +03:00
Flowseal e8ad0d6958 refill pool after rotating expired connections 2026-09-03 17:25:21 +03:00
Flowseal 5eb7e007d3 try ws_pool if connection is timed out 2026-09-03 17:09:18 +03:00
Flowseal 1ae2f3a147 todo #1232 2026-09-03 05:14:03 +03:00
Flowseal d2b6c450eb TODOs 2026-09-03 05:11:03 +03:00
Matt Van Horn 1b8671b3d3 fix: restore Linux Mint 21 binary compatibility (#1238)
Fixes #1144
2026-09-01 18:27:13 +03:00
Flowseal b2a8074c59 version bump 2026-08-13 14:48:53 +03:00
delewer 0d459995f3 chore: generic fixes (#1187) 2026-08-13 14:44:09 +03:00
Flowseal b8a5634008 fix contributing path 2026-08-07 22:40:35 +03:00
Flowseal de4179305c docs duplicate for comp 2026-08-07 22:33:36 +03:00
Fallout e3958984c4 Добавить двуязычную документацию (русский и английский) (#1101) 2026-08-07 21:14:52 +03:00
Flowseal 3e7e266176 fix (macOS): crash fix on settings open 2026-08-07 20:29:26 +03:00
Flowseal fba86856db unpin certifi version 2026-08-07 14:04:13 +03:00
partoftheworlD 8b05ba79ae Fix 9f9e1c2 (#1183) 2026-08-07 14:02:54 +03:00
Flowseal 9f9e1c2482 MacOS moved to tkinter and pystray; cetifi implementation to fix SSL issues 2026-08-07 00:41:20 +03:00
Flowseal 2496004c93 move locales path to be handled by pyinstaller 2026-08-04 15:36:50 +03:00
Flowseal b7ff77f550 tooltips moved to customtkinter; closes #1176 2026-08-04 15:24:09 +03:00
Flowseal 2995ae7436 Save user domains instead of erasing; closes #1177 2026-08-04 15:02:18 +03:00
Flowseal 02e52da3a7 removed unused config var 2026-08-04 13:44:56 +03:00
Flowseal 2a699bb91b Version bump 2026-07-31 16:53:31 +03:00
Flowseal a5b2b73dfd Updated icon 2026-07-31 16:52:17 +03:00
Flowseal aee473c9f9 CF Worker pool refactoring 2026-07-31 16:38:42 +03:00
Flowseal 41e97c6a05 hard limit workers pool 2026-07-31 14:20:58 +03:00
Flowseal ecf3d6f3a1 Fixes #1161 fixes #1155 2026-07-31 13:41:23 +03:00
59 changed files with 2458 additions and 752 deletions
+8 -1
View File
@@ -303,6 +303,12 @@ jobs:
python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.13.0
- name: Validate macOS GUI dependencies
run: |
python3.12 -m pip check
python3.12 -m py_compile macos.py
python3.12 -c "import AppKit, customtkinter, macos, pystray, tkinter"
- name: Create macOS icon
run: |
set -euo pipefail
@@ -318,6 +324,7 @@ jobs:
'dist/TG WS Proxy.app/Contents/Info.plist')"
test -n "$ICON_FILE"
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
test -d "dist/TG WS Proxy.app/Contents/Resources/customtkinter"
found=0
while IFS= read -r -d '' file; do
@@ -375,7 +382,7 @@ jobs:
path: dist/TgWsProxy_macos_universal.dmg
build-linux:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
if: ${{ github.event.inputs.build_linux == 'true' }}
steps:
- name: Checkout
+2 -1
View File
@@ -15,7 +15,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi
FROM python:3.12-slim AS runtime
@@ -37,6 +37,7 @@ RUN apt-get update \
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY proxy ./proxy
COPY utils ./utils
COPY docs/README.md LICENSE ./
USER app
+15 -1
View File
@@ -37,12 +37,26 @@ pip install -e .
Подробности: `docs/BuildFromSource.md`.
## Проверки
Тесты используют только стандартную библиотеку, дополнительные зависимости не нужны:
```bash
python -m unittest discover -s tests -t .
```
Линтер (`ruff` настроен в `pyproject.toml`):
```bash
ruff check .
```
## Pull Request
Перед открытием PR:
1. Убедитесь, что изменение решает конкретную проблему.
2. Проверьте, что не сломаны существующие сценарии.
2. Проверьте, что не сломаны существующие сценарии: запустите тесты и линтер.
3. Обновите документацию, если меняется поведение или настройка.
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
+1 -1
View File
@@ -32,7 +32,7 @@ workers.dev
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
* Пример домена: `random-symbols-1234.username.workers.dev`
* Пример домена: `random-symbols-1234.username.workers.dev`
* **Можно указывать несколько доменов через запятую (или повторением аргумента `--cfproxy-worker-domain`)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
+77
View File
@@ -0,0 +1,77 @@
# Building from Source
## Console Proxy
To run only the proxy without the system tray interface, basic installation is sufficient:
```bash
pip install -e .
tg-ws-proxy
```
## Tray Application by OS
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
Requires a Python build with Tk support. You can verify it with the command `python3 -m tkinter`.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
## Console Mode from Source
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Arguments:**
| Argument | Default | Description |
|---|---|---|
| `--port` | `1443` | Proxy port |
| `--host` | `127.0.0.1` | Proxy host |
| `--secret` | `random` | 32-character hex key for client authorization |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Target IP for DC (can be specified multiple times) |
| `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts |
| `--cfproxy-domain` | | Specify your own domain for Cloudflare proxying [Learn more](./CfProxy.md). Can be specified multiple times. |
| `--cfproxy-worker-domain` | | Cloudflare Worker domain [Learn more](./CfWorker.md). Can be specified multiple times. |
| `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain |
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
| `--buf-kb` | `256` | Buffer size in KB |
| `--pool-size` | `4` | Number of pre-allocated connections per DC |
| `--log-file` | disabled | Path to file for saving logs |
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
| `--log-backups` | `0` | Number of log backups after overwrite |
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
**Examples:**
```bash
# Standard startup
tg-ws-proxy
# Different port and additional DCs
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# With verbose logging
tg-ws-proxy -v
# Fake TLS masquerading (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
```
+62
View File
@@ -0,0 +1,62 @@
# CONTRIBUTING
Thank you for wanting to help the `tg-ws-proxy` project.
## Before Creating an Issue
1. Check the documentation in `docs/README.md`.
2. Make sure a similar issue hasn't already been opened.
3. Use standard labels from `.github/labels.md` for correct triage.
## How to Report Problems
- Use the `Problem` template.
- If possible, provide:
- Application version,
- Operating system,
- Steps to reproduce,
- Expected and actual behavior,
- Log file or error text.
The more precise your description, the faster we can help.
## Local Development from Source
Python `>=3.8` is required.
```bash
pip install -e .
```
Running:
- console mode: `tg-ws-proxy`
- Windows tray: `tg-ws-proxy-tray-win`
- macOS tray: `tg-ws-proxy-tray-macos`
- Linux tray: `tg-ws-proxy-tray-linux`
Details: `docs/BuildFromSource.md`.
## Checks
Tests use the standard library only, no extra dependencies:
```bash
python -m unittest discover -s tests -t .
```
Linting (`ruff` is configured in `pyproject.toml`):
```bash
ruff check .
```
## Pull Request
Before opening a PR:
1. Make sure your change solves a specific problem.
2. Check that existing scenarios aren't broken; run the tests and the linter.
3. Update documentation if behavior or configuration changes.
Smaller and focused PRs are reviewed and accepted faster.
+32
View File
@@ -0,0 +1,32 @@
# Cloudflare Proxy
An alternative, free connection method is proxying through Cloudflare, which can be used for unreachable data centers. **All you need to get it working is a domain**. The application includes a default domain, but it can (and ideally should) be replaced with your own.
The proxy restores access to content that previously wouldn't load (reactions, certain stickers). If you are using a non-Premium account and photos/videos still fail to load, leave only `4:149.154.167.220` in the `DC → IP` block. If the CF proxy works, media will start loading again.
## Why should I set up my own domain?
Cloudflare limits the number of simultaneous WebSocket (WS) connections. The default domain could stop working at any moment.
## Setting up your own domain
1. Add your domain to Cloudflare (either by purchasing it directly from Cloudflare or by changing the NS servers: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Domains cost around $1.50$2.00 per year, and any domain extension will work.
2. In `SSL/TLS``Overview`, set the mode to **Flexible**.
3. In `DNS``Records`, add the following `A` records via `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100`
- Name=`kws4` IPv4=`149.154.167.91`
- Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100`
4. **Add your domain to [zapret](https://github.com/Flowseal/zapret-discord-youtube/) or any other DPI bypass software, as the Cloudflare subnet may be blocked (e.g., in Russia).**
5. In the `TgWsProxy` settings, replace the default domain with your own.
## Credits / Acknowledgments
- Original Idea: https://github.com/Nekogram/WSProxy
- Special thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for providing the information.
+129
View File
@@ -0,0 +1,129 @@
# Cloudflare Worker
An alternative (completely free, no domain purchase required unlike [CfProxy](./CfProxy.md)) method for proxying.
The proxy restores access to content that previously wouldn't load (reactions, certain stickers). If you are using a non-Premium account with this method and photos/videos still fail to load, leave only `4:149.154.167.220` in the `DC → IP` block.
##
1. **Add the following domains to [zapret](https://github.com/Flowseal/zapret-discord-youtube/) or any other DPI bypass software:**
```
cloudflare.com
cloudflare.dev
workers.dev
```
2. Create an account on [Cloudflare](https://dash.cloudflare.com/) (or log into an existing one)
* **After creating your account, verify your email using the link sent to your inbox**
3. Select `Compute``Workers & Pages` from the left panel
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
4. Click the **`Create application`** button in the top right → `Start with Hello World!``Deploy`
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
5. Click the **`Edit code`** button in the top right, then replace the code on the left with the one [found at the bottom of this page](#worker-code)
* If the code section fails to load, it means you missed the first step
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
6. Click the **`Deploy`** button in the top right
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Copy the domain from the field on the right and specify it in your **Cloudflare Worker** settings (or via the `--cfproxy-worker-domain` argument)
* Example domain: `random-symbols-1234.username.workers.dev`
* **You can specify multiple domains separated by commas (or by repeating the `--cfproxy-worker-domain` argument)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
### Worker Code
```javascript
import { connect } from "cloudflare:sockets";
function toBytes(data) {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data && typeof data.arrayBuffer === "function") {
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
}
return new Uint8Array();
}
export default {
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
return new Response("Not found", { status: 404 });
}
const dst = url.searchParams.get("dst");
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
const socket = connect({ hostname: dst, port: 443 });
const tcpReader = socket.readable.getReader();
const tcpWriter = socket.writable.getWriter();
server.addEventListener("message", async (event) => {
try {
await tcpWriter.write(await toBytes(event.data));
} catch {
try {
server.close(1011, "tcp write failed");
} catch {}
}
});
server.addEventListener("close", async () => {
try {
await tcpWriter.close();
} catch {}
try {
socket.close();
} catch {}
});
(async () => {
try {
while (true) {
const { value, done } = await tcpReader.read();
if (done) {
break;
}
if (value) {
server.send(value);
}
}
} catch {
} finally {
try {
server.close();
} catch {}
try {
tcpReader.releaseLock();
} catch {}
try {
socket.close();
} catch {}
}
})();
return new Response(null, { status: 101, webSocket: client });
},
};
```
+52
View File
@@ -0,0 +1,52 @@
# Fake TLS + Upstream in Nginx
The domain in the `--fake-tls-domain` parameter should point to the same IP where the proxy is running.
## Example `nginx.conf` for Stream Module
```nginx
upstream mtproto {
server 127.0.0.1:8446;
}
map $ssl_preread_server_name $sni_name {
hostnames;
example.com mtproto;
# if you have xray with selfsni running:
# sub.example.com www;
# default xray;
}
# upstream xray {
# server 127.0.0.1:8443;
# }
#
# upstream www {
# server 127.0.0.1:7443;
# }
server {
proxy_protocol on;
set_real_ip_from unix:;
listen 443;
proxy_pass $sni_name;
ssl_preread on;
}
```
## Running Proxy Behind Nginx
```bash
python3 proxy/tg_ws_proxy.py \
--port 8446 \
--host 127.0.0.1 \
--fake-tls-domain example.com \
--proxy-protocol \
--secret <32-hex-chars>
```
The connection link will be in `ee`-secret format:
```text
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
```
+12
View File
@@ -0,0 +1,12 @@
> [!TIP]
>
> ### 🎉 Support Me
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
The project is completely free for everyone.
However, its development and stable operation as the user base grows require investment.
I would appreciate any form of support! Thank you ❤️
+70
View File
@@ -0,0 +1,70 @@
# TG WS Proxy for Docker
## Installation from Source
Enter the commands sequentially, one by one:
```bash
# Clone the repository
git clone https://github.com/Flowseal/tg-ws-proxy.git
# Navigate to the project folder
cd tg-ws-proxy
# Build the image
docker build -t tg-ws-proxy .
# Run the container
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
tg-ws-proxy:latest
# Get the connection link
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
```
After running the last command, you will see a link like:
```text
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
```
## Configuring Parameters
All settings are configured using environment variables when running the container:
| Variable | Description | Default |
| ----------------------- | -------------------------------- | --------------------------------- |
| `TG_WS_PROXY_HOST` | Address for incoming connections | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | Port inside the container | `1443` |
| `TG_WS_PROXY_SECRET` | Secret key | `random` |
| `TG_WS_PROXY_DC_IPS` | DC number:IP pairs separated by space | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | Cloudflare Worker domain | `None` |
Example with manually specified secret:
```bash
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
-e TG_WS_PROXY_SECRET="your_secret" \
tg-ws-proxy:latest
```
To generate a secret, you can use:
```bash
openssl rand -hex 16
```
## Configuring Telegram Desktop
1. Telegram → **Settings****Advanced****Connection type****Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
+51
View File
@@ -0,0 +1,51 @@
# TG WS Proxy for Linux
## Prebuilt Packages
For Debian/Ubuntu, download the `TgWsProxy_linux_amd64.deb` package from the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases).
For Arch and Arch-based distributions, packages are available in AUR:
- [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin)
- [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git)
- [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Installation without AUR helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# Using AUR helper
paru -S tg-ws-proxy-bin
# For -cli package, run via systemd (8888 — port number; secret can be generated with openssl rand -hex 16)
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
```
For other distributions, you can use `TgWsProxy_linux_amd64` (binary for x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
## Configuring Telegram Desktop
1. Telegram → **Settings****Advanced****Connection type****Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
+32
View File
@@ -0,0 +1,32 @@
# TG WS Proxy for macOS
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download `TgWsProxy_macos_universal.dmg` (universal build for Apple Silicon and Intel).
1. Open the image
2. Drag `TG WS Proxy.app` to the `Applications` folder
3. On first launch, macOS may ask for confirmation: **System Settings → Privacy & Security → Open Anyway**
Minimum supported versions:
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
## Configuring Telegram Desktop
1. Telegram → **Settings****Advanced****Connection type****Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
The interface requires Tk, CustomTkinter, and access to Cocoa via PyObjC. They are installed automatically, except for Tk, which must be included in your Python build.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
+145
View File
@@ -0,0 +1,145 @@
<div align="center">
**[🇷🇺 Русский](../README.md) • 🇬🇧 English**
</div>
<div align="center">
<br />
<p>
<img width="1729" height="910" alt="tgwsproxy" src="../images/workflow.png" />
</p>
</div>
##
> [!TIP]
>
> ### [🎉 Support Me](../EN/Funding.md)
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
> [!CAUTION]
>
> ### Antivirus Detection
>
> Antivirus software sometimes incorrectly marks the application as a virus due to the packer.
> If you cannot download due to antivirus blocking, then:
>
> 1) **Try downloading the Windows 7 version (functionally identical)**
> 2) Temporarily disable antivirus during download, add the file to exclusions, then re-enable
>
> Always verify what you download from the internet, especially from untrusted sources. It's best to check detections from well-known antivirus vendors on VirusTotal.
# TG WS Proxy
**Local MTProto proxy** for Telegram Desktop that **speeds up Telegram**, redirecting traffic through WebSocket connections. Data is transmitted in the same encrypted form, and no external servers are needed.
<picture>
<source srcset="../images/preview-dark.png" media="(prefers-color-scheme: dark)">
<img src="../images/preview-white.png">
</picture>
## Navigation
- **🚀 Quick Start**
- **[Windows](./README.windows.md)**
- **[macOS](./README.macos.md)**
- **[Linux](./README.linux.md)**
- **[Docker](./README.docker.md)**
- [Cloudflare Worker Setup (free alternative to CF proxy)](./CfWorker.md)
- [Cloudflare Domain Setup (CF proxy)](./CfProxy.md)
- [Telegram Test Environment (Test DCs)](./TestDc.md)
- [Fake TLS + upstream in Nginx](./FakeTlsNginx.md)
- [Tray Application Configuration Files](./TrayConfig.md)
- [Building from Source](./BuildFromSource.md)
- [Contributor Guide](./CONTRIBUTING.md)
## Windows: Quick Start
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download:
- `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
On first launch, a window will open with instructions for connecting Telegram Desktop. **The application minimizes to system tray.**
### Tray Menu
- **Open in Telegram** — automatically configure proxy via `tg://proxy` link
- **Copy Link** — copy the proxy connection link
- **Restart Proxy** — restart without exiting the application
- **Settings...** — GUI configuration editor (app version, optional GitHub update checks)
- **Open Logs** — open log file
- **Exit** — stop proxy and close application
### Configuring Telegram Desktop
**Automatic Setup**
Right-click the tray icon and select **"Open in Telegram"**.
If it doesn't work (Telegram doesn't open with proxy), follow these steps:
1. Right-click the tray icon and select **"Copy Link"**
2. Send the link to "Saved Messages" in Telegram and click it
3. Connect
**Manual Setup**
1. Telegram → **Settings****Advanced****Connection type****Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## How It Works
```
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
```
1. Application starts MTProto proxy on `127.0.0.1:1443`
2. Intercepts connections to Telegram IP addresses
3. Extracts DC ID from MTProto obfuscation init packet
4. Establishes WebSocket connection (TLS) to corresponding DC via Telegram domains
5. If WS unavailable (302 redirect) — automatically switches to CfProxy / direct TCP connection
> [!IMPORTANT]
> ### Photos/Videos Not Loading?
> **In proxy settings, leave only `4:149.154.167.220` in DC → IP**
> **If that doesn't work, clear the field completely**
> This issue occurs on non-Premium accounts
> If still not working, set up your own domain following: [CfProxy.md](./CfProxy.md)
## Automatic Build
The project contains PyInstaller specs ([`packaging/windows.spec`](../../packaging/windows.spec), [`packaging/macos.spec`](../../packaging/macos.spec), [`packaging/linux.spec`](../../packaging/linux.spec)) and GitHub Actions workflow ([`.github/workflows/build.yml`](../../.github/workflows/build.yml)) for automated builds.
Minimum supported OS versions for current binary builds:
- Windows 10+ x64 for `TgWsProxy_windows.exe`
- Windows 10+ ARM64 for `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) for `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) for `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (AppIndicator required for system tray)
## Contributors
Thanks to everyone who helps develop this project ❤️
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
</a>
## License
[MIT License](../../LICENSE)
+58
View File
@@ -0,0 +1,58 @@
# TG WS Proxy for Windows
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download:
- `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
Builds are published automatically via [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) from open source code.
On first launch, a window will open with instructions for connecting Telegram Desktop. **The application minimizes to system tray.**
## Tray Menu
- **Open in Telegram** — automatically configure proxy via `tg://proxy` link
- **Copy Link** — copy the proxy connection link
- **Restart Proxy** — restart without exiting the application
- **Settings...** — GUI configuration editor (app version, optional GitHub update checks)
- **Open Logs** — open log file
- **Exit** — stop proxy and close application
On first launch after startup, you may be prompted to open the release page if a new version is available on GitHub (this check can be disabled in settings).
## Configuring Telegram Desktop
### Automatic Setup
Right-click the tray icon and select **"Open in Telegram"**.
If it doesn't work (Telegram doesn't open with proxy), follow these steps:
1. Right-click the tray icon and select **"Copy Link"**
2. Send the link to "Saved Messages" in Telegram and click it
3. Connect
### Manual Setup
1. Telegram → **Settings****Advanced****Connection type****Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Portable Mode
Portable mode is automatically enabled if a folder named `TgWsProxy_data` exists next to the executable.
You can also force portable mode by running the executable with the `--portable` parameter (it will create the folder).
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-win
```
+23
View File
@@ -0,0 +1,23 @@
# Telegram Test Environment (Test DCs)
Traffic routing to Telegram test data centers (test environment).
Useful for developing/testing bots and clients within the Telegram test environment.
## How to Enable
**Automatically.** Telegram Desktop marks test DCs with a +10000
offset (1000110003). The proxy automatically detects this offset — no configuration needed, allowing
you to use production and test accounts simultaneously in a single client.
**Forced.** For clients that report test DCs as standard 1-3
(Telethon, TDLib) — they cannot be detected automatically. In this case, all traffic
is forcibly routed to test DCs (production accounts will stop working through this proxy).
To force this behavior, use the `--force-test-dc` flag in CLI:
```bash
tg-ws-proxy --force-test-dc # + your --secret / --port
```
## Limitations
Only works for **direct DC → IP** and **Cloudflare Worker** routes (see [Setting up a Cloudflare Worker](./CfWorker.md)).
+32
View File
@@ -0,0 +1,32 @@
# Tray Application Configuration Files
The tray application stores data in:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (or `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"host": "127.0.0.1",
"port": 1443,
"secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"check_updates": true,
"cfproxy": true,
"cfproxy_user_domain": "",
"cfproxy_worker_domain": "",
"force_test_dc": false,
"appearance": "auto"
}
```
The `check_updates` key: when `true`, performs a request to GitHub and compares the current version with the latest release (notification and link to download page only).
On Windows, the config may contain `autostart` (auto-start on system login).
+18 -12
View File
@@ -1,3 +1,9 @@
<div align="center">
**🇷🇺 Русский • [🇬🇧 English](./EN/README.md)**
</div>
<div align="center">
<br />
<p>
@@ -9,7 +15,7 @@
> [!TIP]
>
> ### [🎉 Поддержать меня](./Funding.md)
> ### [🎉 Поддержать меня](./RU/Funding.md)
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
@@ -40,16 +46,16 @@
## Навигация
- **🚀 Быстрый старт**
- **[Windows](./README.windows.md)**
- **[macOS](./README.macos.md)**
- **[Linux](./README.linux.md)**
- **[Docker](./README.docker.md)**
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md)
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
- [Тестовое окружение Telegram (тестовые DC)](./TestDc.md)
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
- [Установка из исходников](./BuildFromSource.md)
- **[Windows](./RU/README.windows.md)**
- **[macOS](./RU/README.macos.md)**
- **[Linux](./RU/README.linux.md)**
- **[Docker](./RU/README.docker.md)**
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./RU/CfWorker.md)
- [Настройка Cloudflare-домена (CF-прокси)](./RU/CfProxy.md)
- [Тестовое окружение Telegram (тестовые DC)](./RU/TestDc.md)
- [Fake TLS + upstream в Nginx](./RU/FakeTlsNginx.md)
- [Файлы конфигурации Tray-приложения](./RU/TrayConfig.md)
- [Установка из исходников](./RU/BuildFromSource.md)
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
## Windows: быстрый вход
@@ -110,7 +116,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
> **Если это не помогло, полностью очистите это поле**
> Подобная проблема встречается на аккаунтах без Premium
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md)
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./RU/CfProxy.md)
## Автоматическая сборка
@@ -20,6 +20,8 @@ tg-ws-proxy-tray-win
### macOS
Требуется сборка Python с поддержкой Tk. Проверить её можно командой `python3 -m tkinter`.
```bash
pip install -e .
tg-ws-proxy-tray-macos
+32
View File
@@ -0,0 +1,32 @@
# Cloudflare-прокси
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
## Зачем мне настраивать свой домен?
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
## Настройка своего домена
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
2. В `SSL/TLS``Overview` выставьте режим **Flexible**.
3. В `DNS``Records` добавьте следующие `A`-записи через `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100`
- Name=`kws4` IPv4=`149.154.167.91`
- Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100`
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
5. В настройках `TgWsProxy` замените домен на свой.
## Благодарности
- Идея: https://github.com/Nekogram/WSProxy
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
+125
View File
@@ -0,0 +1,125 @@
# Cloudflare Worker
Альтернативный (полностью бесплатный, не нужно покупать домен в отличии от [CfProxy](./CfProxy.md)) способ проксирования.
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium с данным способом все еще не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`
##
1. **Добавьте в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО следующие домены:**
```
cloudflare.com
cloudflare.dev
workers.dev
```
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
3. Слева в панели выберите `Compute``Workers & Pages`
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
4. Нажмите сверху справа кнопку **`Create application`** → `Start with Hello World!``Deploy`
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
5. Сверху справа нажмите кнопку **`Edit code`**, замените код слева на тот, [что находится внизу этой страницы](./CfWorker.md#код-workerа)
* Если у вас не загружается код, то вы не выполнили первый пункт
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
6. Нажмите сверху справа кнопку **`Deploy`**
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
* Пример домена: `random-symbols-1234.username.workers.dev`
* **Можно указывать несколько доменов через запятую (или повторением аргумента `--cfproxy-worker-domain`)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
### Код Worker'а
```javascript
import { connect } from "cloudflare:sockets";
function toBytes(data) {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data && typeof data.arrayBuffer === "function") {
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
}
return new Uint8Array();
}
export default {
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
return new Response("Not found", { status: 404 });
}
const dst = url.searchParams.get("dst");
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
const socket = connect({ hostname: dst, port: 443 });
const tcpReader = socket.readable.getReader();
const tcpWriter = socket.writable.getWriter();
server.addEventListener("message", async (event) => {
try {
await tcpWriter.write(await toBytes(event.data));
} catch {
try {
server.close(1011, "tcp write failed");
} catch {}
}
});
server.addEventListener("close", async () => {
try {
await tcpWriter.close();
} catch {}
try {
socket.close();
} catch {}
});
(async () => {
try {
while (true) {
const { value, done } = await tcpReader.read();
if (done) {
break;
}
if (value) {
server.send(value);
}
}
} catch {
} finally {
try {
server.close();
} catch {}
try {
tcpReader.releaseLock();
} catch {}
try {
socket.close();
} catch {}
}
})();
return new Response(null, { status: 101, webSocket: client });
},
};
```
+12
View File
@@ -0,0 +1,12 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
Буду благодарен за любую форму поддержки! Спасибо ❤️
@@ -24,6 +24,8 @@
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
Для интерфейса требуются Tk, CustomTkinter и доступ к Cocoa через PyObjC. Они устанавливаются автоматически, кроме Tk, который должен входить в используемую сборку Python.
```bash
pip install -e .
tg-ws-proxy-tray-macos
+6 -2
View File
@@ -12,8 +12,12 @@
**Принудительно.** Для клиентов, которые сообщают тестовые DC как обычные 1-3
(Telethon, TDLib) — распознать их автоматически нельзя. Тогда весь трафик
принудительно направляется на тестовые DC (продовые аккаунты через этот прокси
работать перестанут). Для принудительной работы замените в конфиге force_test_dc на true или используйте флаг `--force-test-dc` в CLI
работать перестанут). Для принудительной работы используйте флаг `--force-test-dc` в CLI:
```bash
tg-ws-proxy --force-test-dc # + ваши --secret / --port
```
## Ограничения
Работает только для маршрутов **прямой DC → IP** и **Cloudflare Worker** (см. [Настройка Cloudflare Worker'а](./CfWorker.md)).
Работает только для маршрутов **прямой DC → IP** и **Cloudflare Worker** (см. [Настройка Cloudflare Worker'а](./CfWorker.md)).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 22 KiB

+3 -3
View File
@@ -147,8 +147,8 @@ def _edit_config_dialog() -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=_apply_window_icon,
ctk, title=t("app.settings_title"), width=w, height=h,
theme=theme, topmost=False, after_create=_apply_window_icon
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -244,7 +244,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=_apply_window_icon,
topmost=False, after_create=_apply_window_icon,
)
def on_done(open_tg: bool) -> None:
+446 -521
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,6 +11,7 @@ block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
@@ -28,7 +29,7 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + gi_datas + typelib_datas,
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas,
hiddenimports=[
'pystray._appindicator',
'PIL._tkinter_finder',
+13 -9
View File
@@ -1,24 +1,31 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
pathex=[],
binaries=[],
datas=[(_i18n_path, 'ui/i18n')],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'rumps',
'tkinter',
'customtkinter',
'pystray._darwin',
'PIL._tkinter_finder',
'objc',
'Foundation',
'AppKit',
'PyObjCTools',
'PyObjCTools.AppHelper',
'PyObjCTools.MachSignals',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
@@ -30,14 +37,13 @@ a = Analysis(
excludes=[
'PIL._avif',
'PIL._webp',
'PIL._imagingtk',
],
noarchive=False,
cipher=block_cipher,
)
_PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk',
'_avif', '_webp',
'FpxImagePlugin', 'MicImagePlugin',
}
a.binaries = [
@@ -93,7 +99,5 @@ app = BUNDLE(
'LSMinimumSystemVersion': '10.15',
'LSUIElement': True,
'NSHighResolutionCapable': True,
'NSAppleEventsUsageDescription':
'TG WS Proxy needs to display dialogs.',
},
)
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 9, 0, 0),
prodvers=(1, 9, 0, 0),
filevers=(1, 10, 0, 0),
prodvers=(1, 10, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[
StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.9.0.0'),
StringStruct(u'FileVersion', u'1.10.0.0'),
StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.9.0.0'),
StringStruct(u'ProductVersion', u'1.10.0.0'),
]
)
]
+4 -1
View File
@@ -3,11 +3,14 @@
import sys
import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
@@ -15,7 +18,7 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'pystray._win32',
'PIL._tkinter_finder',
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener
__version__ = "1.9.0"
__version__ = "1.10.1"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+26 -22
View File
@@ -1,7 +1,6 @@
import asyncio
import logging
import struct
import random
from typing import List, Optional
from urllib.parse import urlencode
@@ -181,39 +180,44 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
worker_domains = proxy_config.cfproxy_worker_domains
if not worker_domains:
return False
random.shuffle(worker_domains)
for worker_domain in worker_domains:
ws = None if is_test_dc else await cf_worker_pool.get(dc, worker_domain, fallback_dst)
if ws:
log.info("[%s] DC%d%s -> CF worker pool hit for %s",
label, dc, media_tag, fallback_dst)
else:
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
pooled = None if is_test_dc else await cf_worker_pool.get(
dc, fallback_dst, worker_domains)
if pooled:
ws, worker_domain = pooled
log.info("[%s] DC%d%s -> CF worker pool hit via %s for %s",
label, dc, media_tag, worker_domain, fallback_dst)
else:
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
ws = None
for worker_domain in cf_worker_pool.available_domains(worker_domains):
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
label, dc, media_tag, worker_domain, fallback_dst)
try:
ws = await RawWebSocket.connect(worker_domain, worker_domain,
timeout=10.0, path=path)
break
except Exception as exc:
cf_worker_pool.report_failure(worker_domain, exc)
log.warning("[%s] DC%d%s CF worker %s failed: %s",
label, dc, media_tag, worker_domain, repr(exc))
continue
stats.connections_cfproxy += 1
await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media,
splitter=splitter)
return True
return False
if ws is None:
return False
stats.connections_cfproxy += 1
await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media,
splitter=None)
return True
async def _cfproxy_fallback(reader, writer, relay_init, label,
@@ -422,4 +426,4 @@ async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
w.close()
await w.wait_closed()
except BaseException:
pass
pass
+2 -2
View File
@@ -209,11 +209,11 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
dc_s, ip_s = entry.split(':', 1)
try:
dc_n = int(dc_s)
_socket.inet_aton(ip_s)
_socket.inet_pton(_socket.AF_INET, ip_s)
except (ValueError, OSError):
err = ValueError(f"Invalid --dc-ip {entry!r}")
err.entry = entry
err.kind = "invalid"
raise err
raise err from None
dc_redirects[dc_n] = ip_s
return dc_redirects
+86 -50
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import random
import time
from collections import deque
@@ -13,10 +14,12 @@ from .utils import ws_domains, DC_DEFAULT_IPS
log = logging.getLogger('tg-mtproto-proxy')
# TODO: domains handling is broken: wrong is_media flag causes tcp_reset after handshake,
# but initial connection is still established no matter what is is_media flag is set to
class _WsPool:
WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 60.0
REFILL_BACKOFF_INITIAL = 1.0
REFILL_BACKOFF_MAX = 3600.0
def __init__(self):
@@ -25,11 +28,10 @@ class _WsPool:
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool], float] = {}
self.try_fronting_first = False
self.try_fronting_first = True
async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str],
*, allow_refill: bool = True
target_ip: str, domains: List[str]
) -> Optional[RawWebSocket]:
key = (dc, is_media)
now = time.monotonic()
@@ -49,13 +51,11 @@ class _WsPool:
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self.report_success(dc, is_media)
if allow_refill:
self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return ws
stats.pool_misses += 1
if allow_refill:
self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return None
def _schedule_refill(self, key, target_ip, domains):
@@ -97,7 +97,7 @@ class _WsPool:
self._refill_failures[key] = failures
delay = min(
self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 6)),
* (2 ** min(failures - 1, 12)),
self.REFILL_BACKOFF_MAX,
)
self._refill_after[key] = time.monotonic() + delay
@@ -149,6 +149,8 @@ class _WsPool:
log.debug(
"WS pool rotated DC%d%s: %d stale, %d ready",
dc, 'm' if is_media else '', len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains)
finally:
if self._rotating.get(key) is asyncio.current_task():
@@ -165,7 +167,7 @@ class _WsPool:
target_ip, domain, timeout=8)
self.try_fronting_first = False
return ws
except asyncio.TimeoutError:
except (asyncio.TimeoutError, ConnectionResetError):
if self.try_fronting_first:
return None
return await self._connect_fronted(target_ip, domain)
@@ -218,75 +220,108 @@ class _WsPool:
class _CfWorkerPool:
WS_POOL_MAX_AGE = 100.0
PER_DC_LIMIT = 1
def __init__(self):
self._idle: Dict[Tuple[int, str], deque] = {}
self._refilling: Set[Tuple[int, str]] = set()
self._idle: Dict[int, deque] = {}
self._refilling: Set[int] = set()
self._exhausted_until: Dict[str, float] = {}
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
async def get(self, dc: int, fallback_dst: str,
worker_domains: List[str]
) -> Optional[Tuple[RawWebSocket, str]]:
now = time.monotonic()
key = (dc, worker_domain)
bucket = self._idle.get(key)
bucket = self._idle.get(dc)
if bucket is None:
bucket = deque()
self._idle[key] = bucket
self._idle[dc] = bucket
while bucket:
ws, created = bucket.popleft()
ws, created, worker_domain = bucket.popleft()
age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws))
continue
stats.cf_pool_hits += 1
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
dc, age, len(bucket))
self._schedule_refill(key, fallback_dst)
return ws
log.debug(
"CF worker pool hit DC%d via %s (age=%.1fs, left=%d)",
dc, worker_domain, age, len(bucket))
self._schedule_refill(dc, fallback_dst, worker_domains)
return ws, worker_domain
stats.cf_pool_misses += 1
self._schedule_refill(key, fallback_dst)
return None
def _schedule_refill(self, key, fallback_dst):
if key in self._refilling:
def _schedule_refill(self, dc, fallback_dst, worker_domains):
if dc in self._refilling:
return
self._refilling.add(key)
asyncio.create_task(self._refill(key, fallback_dst))
self._refilling.add(dc)
asyncio.create_task(self._refill(
dc, fallback_dst, list(worker_domains)))
async def _refill(self, key, fallback_dst):
dc, worker_domain = key
async def _refill(self, dc, fallback_dst, worker_domains):
try:
bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket)
bucket = self._idle.setdefault(dc, deque())
target_size = min(proxy_config.pool_size, self.PER_DC_LIMIT)
needed = target_size - len(bucket)
if needed <= 0:
return
tasks = [asyncio.create_task(
self._connect_one(worker_domain, fallback_dst, dc))
for _ in range(needed)]
for t in tasks:
try:
ws = await t
if ws:
bucket.append((ws, time.monotonic()))
except Exception:
pass
for _ in range(needed):
connected = await self._connect_one(
worker_domains, fallback_dst, dc)
if connected is None:
break
ws, worker_domain = connected
bucket.append((ws, time.monotonic(), worker_domain))
log.debug("CF worker pool refilled DC%d: %d ready",
dc, len(bucket))
finally:
self._refilling.discard(key)
self._refilling.discard(dc)
async def _connect_one(self, worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
async def _connect_one(self, worker_domains, fallback_dst, dc):
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
try:
return await RawWebSocket.connect(
worker_domain, worker_domain, timeout=8, path=path)
except Exception:
return None
for worker_domain in self.available_domains(worker_domains):
try:
ws = await RawWebSocket.connect(
worker_domain, worker_domain, timeout=8, path=path)
return ws, worker_domain
except Exception as exc:
self.report_failure(worker_domain, exc)
return None
def available_domains(self, worker_domains: List[str]) -> List[str]:
now = time.time()
domains = []
for domain in worker_domains:
if domain in domains:
continue
exhausted_until = self._exhausted_until.get(domain, 0)
if exhausted_until > now:
continue
if exhausted_until:
self._exhausted_until.pop(domain, None)
domains.append(domain)
random.shuffle(domains)
return domains
def report_failure(self, worker_domain: str, exc: Exception) -> None:
return # TODO: check status code after daily limit reached
if not isinstance(exc, WsHandshakeError) or exc.status_code != 429:
return
now = time.time()
if self._exhausted_until.get(worker_domain, 0) > now:
return
exhausted_until = now + (86400 - (now % 86400))
self._exhausted_until[worker_domain] = exhausted_until
log.warning(
"CF worker %s reached its request limit, disabled for %d seconds", worker_domain, int(exhausted_until - now))
async def _quiet_close(self, ws):
try:
@@ -303,15 +338,16 @@ class _CfWorkerPool:
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
return
for worker_domain in proxy_config.cfproxy_worker_domains:
for dc, fallback_dst in cf_fallbacks.items():
self._schedule_refill((dc, worker_domain), fallback_dst)
worker_domains = list(proxy_config.cfproxy_worker_domains)
for dc, fallback_dst in cf_fallbacks.items():
self._schedule_refill(dc, fallback_dst, worker_domains)
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
def reset(self):
self._idle.clear()
self._refilling.clear()
self._exhausted_until.clear()
ws_pool = _WsPool()
+24 -7
View File
@@ -67,18 +67,22 @@ def set_sock_opts(transport, buffer_size):
class RawWebSocket:
__slots__ = ('reader', 'writer', '_closed')
__slots__ = ('reader', 'writer', '_closed', '_frag')
OP_CONT = 0x0
OP_BINARY = 0x2
OP_CLOSE = 0x8
OP_PING = 0x9
OP_PONG = 0xA
MAX_MESSAGE_LEN = 16 * 1024 * 1024
def __init__(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
self.reader = reader
self.writer = writer
self._closed = False
self._frag = bytearray()
@staticmethod
async def connect(host: str, domain: str, timeout: float = 10.0,
@@ -164,7 +168,7 @@ class RawWebSocket:
async def recv(self) -> Optional[bytes]:
while not self._closed:
opcode, payload = await self._read_frame()
opcode, payload, fin = await self._read_frame()
if opcode == self.OP_CLOSE:
self._closed = True
@@ -192,8 +196,18 @@ class RawWebSocket:
if opcode == self.OP_PONG:
continue
if opcode in (0x1, 0x2):
return payload
if opcode in (self.OP_CONT, 0x1, self.OP_BINARY):
if fin and not self._frag:
return payload
self._frag.extend(payload)
if len(self._frag) > self.MAX_MESSAGE_LEN:
raise ConnectionError(
f"WS message too large: {len(self._frag)} bytes")
if not fin:
continue
message = bytes(self._frag)
self._frag.clear()
return message
continue
return None
@@ -251,17 +265,20 @@ class RawWebSocket:
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
async def _read_frame(self) -> Tuple[int, bytes]:
async def _read_frame(self) -> Tuple[int, bytes, bool]:
hdr = await self.reader.readexactly(2)
fin = bool(hdr[0] & 0x80)
opcode = hdr[0] & 0x0F
length = hdr[1] & 0x7F
if length == 126:
length = _st_H.unpack(await self.reader.readexactly(2))[0]
elif length == 127:
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
if length > self.MAX_MESSAGE_LEN:
raise ConnectionError(f"WS frame too large: {length} bytes")
if hdr[1] & 0x80:
mask_key = await self.reader.readexactly(4)
payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key)
return opcode, _xor_mask(payload, mask_key), fin
payload = await self.reader.readexactly(length)
return opcode, payload
return opcode, payload, fin
+37 -31
View File
@@ -302,6 +302,8 @@ async def _handle_client(reader, writer, secret: bytes):
ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
target = proxy_config.dc_redirects.get(dc)
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains
domains = ws_domains(dc, is_media)
ws = None
# Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out
if (dc not in proxy_config.dc_redirects
@@ -315,34 +317,40 @@ async def _handle_client(reader, writer, secret: bytes):
log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag)
else:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
label, dc, media_tag, target)
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
# Try to get WS from pool first, might be accidental timeout
ws = await ws_pool.get(
dc, is_media, target, domains
) if not is_test_dc else None
if not ws:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
label, dc, media_tag, target)
else:
log.info("[%s] DC%d%s WS connect to %s was timed out, but pool hit -> using WS",
label, dc, media_tag, target)
if not ws:
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
domains = ws_domains(dc, is_media)
ws = None
ws_failed_redirect = False
ws_timed_out = False
all_redirects = True
allow_pool_refill = now >= ip_fail_until.get(target, 0)
ws = await ws_pool.get(
dc, is_media, target, domains,
allow_refill=allow_pool_refill,
ws = ws or await ws_pool.get(
dc, is_media, target, domains
) if not is_test_dc else None
if ws:
log.info("[%s] DC%d%s -> pool hit via %s",
@@ -398,7 +406,7 @@ async def _handle_client(reader, writer, secret: bytes):
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
log.info("[%s] DC%d%s WS cooldown for %ds",
log.info("[%s] DC%d%s WS failed for %ds",
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
splitter_fb = None
@@ -415,7 +423,6 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, media_tag)
return
dc_fail_until.pop(dc_key, None)
ip_fail_until.pop(target, None)
ws_pool.report_success(dc, is_media)
stats.connections_ws += 1
@@ -474,12 +481,11 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
ip_fail_until.clear()
_client_tasks.clear()
if proxy_config.fallback_cfproxy:
user = proxy_config.cfproxy_user_domains
if user:
balancer.update_domains_list(user)
else:
start_cfproxy_domain_refresh()
user_cf_domains = proxy_config.cfproxy_user_domains
if user_cf_domains:
balancer.update_domains_list(user_cf_domains)
else:
start_cfproxy_domain_refresh()
secret_bytes = bytes.fromhex(proxy_config.secret)
@@ -520,7 +526,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
ip = proxy_config.dc_redirects.get(dc)
log.info(" DC%d: %s", dc, ip)
if proxy_config.fallback_cfproxy:
user_domain = "user" if proxy_config.cfproxy_user_domains else "auto"
user_domain = ", ".join(proxy_config.cfproxy_user_domains) if proxy_config.cfproxy_user_domains else "auto"
log.info(" CF proxy: enabled (%s)", user_domain)
if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)",
+9 -5
View File
@@ -1,6 +1,9 @@
import socket as _socket
import urllib.request
import http.client
import ssl
import certifi
from typing import Optional, Dict, List
from urllib.request import Request
@@ -56,9 +59,9 @@ WS_PATH_TEST = WS_PATH + '_test'
def ws_domains(dc: int, is_media) -> List[str]:
if dc == 203:
dc = 2
if is_media is None or is_media:
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
if not is_media:
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
def human_bytes(n: int) -> str:
@@ -104,10 +107,11 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
)
try:
return self.do_open(_Conn, req)
return self.do_open(_Conn, req, context=self._context)
except Exception:
return super().https_open(req)
def build_github_opener() -> urllib.request.OpenerDirector:
return urllib.request.build_opener(_PinnedHTTPSHandler())
context = ssl.create_default_context(cafile=certifi.where())
return urllib.request.build_opener(_PinnedHTTPSHandler(context=context))
+12 -4
View File
@@ -36,6 +36,7 @@ classifiers = [
dependencies = [
"pyperclip==1.9.0",
"certifi",
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
@@ -45,9 +46,9 @@ dependencies = [
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
"customtkinter==5.2.2; platform_system != 'Darwin'",
"pystray==0.19.5; platform_system != 'Darwin'",
"rumps==0.4.0; platform_system == 'Darwin'",
"customtkinter==5.2.2",
"pystray==0.19.5",
"pyobjc-framework-Cocoa>=9.0; platform_system == 'Darwin'",
"Pillow==12.1.0; platform_system == 'Darwin'",
]
@@ -72,5 +73,12 @@ packages = ["proxy", "ui", "utils"]
[tool.hatch.version]
path = "proxy/__init__.py"
[tool.ruff]
target-version = "py38"
[tool.ruff.lint]
ignore = ["F403", "F405"]
select = ["E4", "E7", "E9", "F", "B", "C4"]
ignore = ["F403", "F405", "B023"]
[tool.ruff.lint.per-file-ignores]
"macos.py" = ["E402"]
View File
+116
View File
@@ -0,0 +1,116 @@
import os
import unittest
from proxy._aes import Cipher, algorithms, modes
from proxy.bridge import MsgSplitter
from proxy.utils import (
PROTO_ABRIDGED_INT,
PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT,
)
def _relay_init() -> bytes:
return os.urandom(64)
def _encryptor(relay_init: bytes):
enc = Cipher(
algorithms.AES(relay_init[8:40]), modes.CTR(relay_init[40:56])
).encryptor()
enc.update(b'\x00' * 64)
return enc
def _abridged(payload: bytes) -> bytes:
words = len(payload) // 4
if words < 0x7F:
return bytes([words]) + payload
return b'\x7f' + words.to_bytes(3, 'little') + payload
def _intermediate(payload: bytes) -> bytes:
return len(payload).to_bytes(4, 'little') + payload
class MsgSplitterTest(unittest.TestCase):
def _split(self, proto_int, packets, chunk_sizes=None):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, proto_int)
enc = _encryptor(relay_init)
stream = enc.update(b''.join(packets))
chunks = []
if chunk_sizes is None:
chunks = [stream]
else:
offset = 0
for size in chunk_sizes:
chunks.append(stream[offset:offset + size])
offset += size
if offset < len(stream):
chunks.append(stream[offset:])
parts = []
for chunk in chunks:
parts.extend(splitter.split(chunk))
return splitter, stream, parts
def test_abridged_stream_splits_into_packets(self):
packets = [_abridged(b'a' * 4), _abridged(b'b' * 16), _abridged(b'c' * 40)]
_, stream, parts = self._split(PROTO_ABRIDGED_INT, packets)
self.assertEqual(len(parts), 3)
self.assertEqual(b''.join(parts), stream)
self.assertEqual([len(p) for p in parts], [5, 17, 41])
def test_intermediate_stream_splits_into_packets(self):
packets = [_intermediate(b'a' * 8), _intermediate(b'b' * 12)]
_, stream, parts = self._split(PROTO_INTERMEDIATE_INT, packets)
self.assertEqual(len(parts), 2)
self.assertEqual(b''.join(parts), stream)
def test_padded_intermediate_uses_intermediate_framing(self):
packets = [_intermediate(b'z' * 20)]
_, stream, parts = self._split(PROTO_PADDED_INTERMEDIATE_INT, packets)
self.assertEqual(parts, [stream])
def test_partial_packet_is_buffered_until_complete(self):
packets = [_abridged(b'a' * 20)]
_, stream, parts = self._split(
PROTO_ABRIDGED_INT, packets, chunk_sizes=[1] * (len(packets[0]) - 1)
)
self.assertEqual(parts, [stream])
def test_split_preserves_stream_across_arbitrary_chunking(self):
packets = [_intermediate(bytes([i]) * 16) for i in range(8)]
_, stream, parts = self._split(
PROTO_INTERMEDIATE_INT, packets, chunk_sizes=[7, 3, 50, 11]
)
self.assertEqual(b''.join(parts), stream)
self.assertEqual(len(parts), 8)
def test_empty_chunk_yields_nothing(self):
splitter = MsgSplitter(_relay_init(), PROTO_INTERMEDIATE_INT)
self.assertEqual(splitter.split(b''), [])
def test_zero_length_packet_disables_splitting(self):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
stream = enc.update((0).to_bytes(4, 'little') + b'tail')
parts = splitter.split(stream)
self.assertEqual(parts, [stream])
self.assertEqual(splitter.split(b'raw'), [b'raw'])
def test_flush_returns_buffered_tail_once(self):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
partial = enc.update(_intermediate(b'x' * 32)[:10])
self.assertEqual(splitter.split(partial), [])
self.assertEqual(splitter.flush(), [partial])
self.assertEqual(splitter.flush(), [])
if __name__ == '__main__':
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
import unittest
from proxy.config import (
CFPROXY_DEFAULT_DOMAINS,
_is_valid_domain,
_normalize_domain_pool,
coerce_domain_list,
parse_dc_ip_list,
)
class ParseDcIpListTest(unittest.TestCase):
def test_parses_multiple_entries(self):
self.assertEqual(
parse_dc_ip_list(['2:149.154.167.220', '4:1.2.3.4']),
{2: '149.154.167.220', 4: '1.2.3.4'},
)
def test_last_entry_wins_for_duplicate_dc(self):
self.assertEqual(parse_dc_ip_list(['2:1.1.1.1', '2:2.2.2.2']), {2: '2.2.2.2'})
def test_rejects_missing_separator(self):
with self.assertRaises(ValueError) as ctx:
parse_dc_ip_list(['2-1.2.3.4'])
self.assertEqual(ctx.exception.kind, 'format')
def test_rejects_short_form_ipv4(self):
for entry in ('2:149.154', '2:1.2.3.4.5', '2:999.1.1.1', '2:abc'):
with self.subTest(entry=entry), self.assertRaises(ValueError) as ctx:
parse_dc_ip_list([entry])
self.assertEqual(ctx.exception.kind, 'invalid')
def test_rejects_non_numeric_dc(self):
with self.assertRaises(ValueError):
parse_dc_ip_list(['x:1.2.3.4'])
class CoerceDomainListTest(unittest.TestCase):
def test_splits_on_common_separators(self):
self.assertEqual(
coerce_domain_list('a.com, b.com; c.com d.com'),
['a.com', 'b.com', 'c.com', 'd.com'],
)
def test_deduplicates_case_insensitively_keeping_first(self):
self.assertEqual(coerce_domain_list(['A.com', 'a.com']), ['A.com'])
def test_flattens_sequences_and_skips_non_strings(self):
self.assertEqual(coerce_domain_list(['a.com b.com', 5, None]), ['a.com', 'b.com'])
def test_returns_empty_for_unsupported_types(self):
self.assertEqual(coerce_domain_list(None), [])
self.assertEqual(coerce_domain_list(42), [])
class DomainValidationTest(unittest.TestCase):
def test_accepts_ordinary_domains(self):
for domain in ('example.com', 'a-b.co.uk', 'x.io'):
self.assertTrue(_is_valid_domain(domain), domain)
def test_rejects_malformed_domains(self):
for domain in ('', 'nodot', '.leading.com', 'trailing.com.',
'-bad.com', 'bad-.com', 'a..com', 'a.1',
'a.' + 'b' * 64, 'a' * 250 + '.com'):
self.assertFalse(_is_valid_domain(domain), domain)
def test_normalize_lowercases_dedupes_and_drops_invalid(self):
self.assertEqual(
_normalize_domain_pool(['B.com ', 'b.com', 'nodot', 'a.com']),
['b.com', 'a.com'],
)
class DefaultDomainsTest(unittest.TestCase):
def test_decoded_defaults_are_valid_domains(self):
self.assertTrue(CFPROXY_DEFAULT_DOMAINS)
for domain in CFPROXY_DEFAULT_DOMAINS:
self.assertTrue(_is_valid_domain(domain), domain)
def test_decoded_defaults_are_unique(self):
self.assertEqual(
len(set(CFPROXY_DEFAULT_DOMAINS)), len(CFPROXY_DEFAULT_DOMAINS)
)
if __name__ == '__main__':
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
import hashlib
import hmac
import os
import struct
import time
import unittest
from proxy.fake_tls import (
CLIENT_RANDOM_LEN,
CLIENT_RANDOM_OFFSET,
SESSION_ID_LEN,
SESSION_ID_OFFSET,
TLS_APPDATA_MAX,
TLS_RECORD_HANDSHAKE,
build_server_hello,
verify_client_hello,
wrap_tls_record,
)
SECRET = bytes.fromhex('00112233445566778899aabbccddeeff')
def _client_hello(secret: bytes = SECRET, timestamp: int = None,
session_id: bytes = None) -> bytes:
if timestamp is None:
timestamp = int(time.time())
if session_id is None:
session_id = os.urandom(SESSION_ID_LEN)
body = bytearray(517)
body[0] = TLS_RECORD_HANDSHAKE
body[1:3] = b'\x03\x01'
struct.pack_into('>H', body, 3, len(body) - 5)
body[5] = 0x01
body[43] = 0x20
body[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN] = session_id
digest = hmac.new(secret, bytes(body), hashlib.sha256).digest()
client_random = bytearray(digest[:CLIENT_RANDOM_LEN])
ts_bytes = struct.pack('<I', timestamp)
for i in range(4):
client_random[28 + i] = digest[28 + i] ^ ts_bytes[i]
body[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = client_random
return bytes(body)
class VerifyClientHelloTest(unittest.TestCase):
def test_accepts_well_formed_hello(self):
session_id = os.urandom(SESSION_ID_LEN)
now = int(time.time())
result = verify_client_hello(_client_hello(timestamp=now,
session_id=session_id), SECRET)
self.assertIsNotNone(result)
client_random, got_session_id, ts = result
self.assertEqual(len(client_random), CLIENT_RANDOM_LEN)
self.assertEqual(got_session_id, session_id)
self.assertEqual(ts, now)
def test_rejects_wrong_secret(self):
other = bytes.fromhex('ffeeddccbbaa99887766554433221100')
self.assertIsNone(verify_client_hello(_client_hello(), other))
def test_rejects_stale_timestamp(self):
stale = int(time.time()) - 3600
self.assertIsNone(verify_client_hello(_client_hello(timestamp=stale), SECRET))
def test_rejects_tampered_body(self):
hello = bytearray(_client_hello())
hello[300] ^= 0xFF
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
def test_rejects_short_and_non_handshake_records(self):
self.assertIsNone(verify_client_hello(b'\x16\x03\x01\x00\x10', SECRET))
hello = bytearray(_client_hello())
hello[0] = 0x17
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
hello = bytearray(_client_hello())
hello[5] = 0x02
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
class BuildServerHelloTest(unittest.TestCase):
def test_echoes_session_id_and_binds_client_random(self):
session_id = os.urandom(SESSION_ID_LEN)
client_random = os.urandom(CLIENT_RANDOM_LEN)
response = build_server_hello(SECRET, client_random, session_id)
self.assertEqual(response[0], TLS_RECORD_HANDSHAKE)
self.assertEqual(
response[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN],
session_id,
)
zeroed = bytearray(response)
zeroed[11:11 + 32] = b'\x00' * 32
expected = hmac.new(SECRET, client_random + bytes(zeroed),
hashlib.sha256).digest()
self.assertEqual(response[11:11 + 32], expected)
def test_padding_length_varies_between_calls(self):
sizes = {
len(build_server_hello(SECRET, os.urandom(32), os.urandom(32)))
for _ in range(20)
}
self.assertGreater(len(sizes), 1)
class WrapTlsRecordTest(unittest.TestCase):
def test_short_payload_becomes_one_record(self):
wrapped = wrap_tls_record(b'hello')
self.assertEqual(wrapped, b'\x17\x03\x03\x00\x05hello')
def test_long_payload_is_chunked_to_the_record_limit(self):
payload = os.urandom(TLS_APPDATA_MAX + 100)
wrapped = wrap_tls_record(payload)
offset = 0
chunks = []
while offset < len(wrapped):
length = struct.unpack('>H', wrapped[offset + 3:offset + 5])[0]
self.assertLessEqual(length, TLS_APPDATA_MAX)
chunks.append(wrapped[offset + 5:offset + 5 + length])
offset += 5 + length
self.assertEqual(len(chunks), 2)
self.assertEqual(b''.join(chunks), payload)
def test_empty_payload_produces_no_records(self):
self.assertEqual(wrap_tls_record(b''), b'')
if __name__ == '__main__':
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
import time
import unittest
from collections import deque
from types import SimpleNamespace
from unittest import mock
from proxy.config import proxy_config
from proxy.pool import _WsPool
class _StopRotation(Exception):
pass
def _open_ws():
transport = SimpleNamespace(is_closing=lambda: False)
writer = SimpleNamespace(transport=transport)
return SimpleNamespace(_closed=False, writer=writer)
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False)
pool._idle[key] = deque([
(_open_ws(), time.monotonic()),
(_open_ws(), time.monotonic()),
])
sleep_calls = 0
async def stop_after_one_iteration(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls > 1:
raise _StopRotation
with mock.patch.object(proxy_config, 'pool_size', 4):
with mock.patch(
'proxy.pool.asyncio.sleep',
side_effect=stop_after_one_iteration):
with mock.patch.object(
pool, '_schedule_refill') as schedule_refill:
with self.assertRaises(_StopRotation):
await pool._rotate(
key, '149.154.167.220', ['example.com'])
schedule_refill.assert_called_once_with(
key, '149.154.167.220', ['example.com'])
if __name__ == '__main__':
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
import asyncio
import unittest
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
def _raw_frame(opcode, data, fin=True):
b0 = (0x80 if fin else 0x00) | opcode
n = len(data)
if n < 126:
return bytes([b0, n]) + data
if n < 65536:
return bytes([b0, 126]) + n.to_bytes(2, 'big') + data
return bytes([b0, 127]) + n.to_bytes(8, 'big') + data
class _NullWriter:
def write(self, data):
pass
async def drain(self):
pass
def _recv(chunks, cls=RawWebSocket):
async def _run():
reader = asyncio.StreamReader()
for chunk in chunks:
reader.feed_data(chunk)
reader.feed_eof()
ws = cls(reader, _NullWriter())
return ws, await ws.recv()
return asyncio.run(_run())
class XorMaskTest(unittest.TestCase):
def test_roundtrip(self):
data = bytes(range(256)) * 3
mask = b'\x01\x02\x03\x04'
self.assertEqual(_xor_mask(_xor_mask(data, mask), mask), data)
def test_empty_payload(self):
self.assertEqual(_xor_mask(b'', b'\x01\x02\x03\x04'), b'')
class BuildFrameTest(unittest.TestCase):
def test_short_unmasked_frame(self):
self.assertEqual(
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
b'\x82\x03abc',
)
def test_extended_length_selects_16bit_header(self):
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'x' * 200)
self.assertEqual(frame[:2], b'\x82\x7e')
self.assertEqual(int.from_bytes(frame[2:4], 'big'), 200)
def test_masked_frame_sets_mask_bit_and_is_reversible(self):
payload = b'payload'
frame = RawWebSocket._build_frame(
RawWebSocket.OP_BINARY, payload, mask=True)
self.assertTrue(frame[1] & 0x80)
self.assertEqual(_xor_mask(frame[6:], frame[2:6]), payload)
class RecvTest(unittest.TestCase):
def test_returns_unfragmented_message(self):
_, msg = _recv([_raw_frame(RawWebSocket.OP_BINARY, b'hello')])
self.assertEqual(msg, b'hello')
def test_reassembles_fragmented_message(self):
_, msg = _recv([
_raw_frame(RawWebSocket.OP_BINARY, b'AAA', False),
_raw_frame(RawWebSocket.OP_CONT, b'BBB', False),
_raw_frame(RawWebSocket.OP_CONT, b'CCC', True),
])
self.assertEqual(msg, b'AAABBBCCC')
def test_control_frame_between_fragments_is_skipped(self):
_, msg = _recv([
_raw_frame(RawWebSocket.OP_BINARY, b'AAA', False),
_raw_frame(RawWebSocket.OP_PONG, b''),
_raw_frame(RawWebSocket.OP_CONT, b'BBB', True),
])
self.assertEqual(msg, b'AAABBB')
def test_close_frame_returns_none(self):
ws, msg = _recv([_raw_frame(RawWebSocket.OP_CLOSE, b'\x03\xe8')])
self.assertIsNone(msg)
self.assertTrue(ws._closed)
def test_oversized_frame_is_rejected_before_reading_payload(self):
header = bytes([0x82, 127]) + (1 << 40).to_bytes(8, 'big')
with self.assertRaises(ConnectionError):
_recv([header])
def test_reassembled_message_exceeding_limit_is_rejected(self):
class _Capped(RawWebSocket):
__slots__ = ()
MAX_MESSAGE_LEN = 1500
chunk = b'x' * 1024
with self.assertRaises(ConnectionError):
_recv([
_raw_frame(RawWebSocket.OP_BINARY, chunk, False),
_raw_frame(RawWebSocket.OP_CONT, chunk, False),
], cls=_Capped)
class ParseCloseTest(unittest.TestCase):
def test_known_code_gets_name(self):
code, reason = RawWebSocket._parse_close(b'\x03\xe8bye')
self.assertEqual(code, 1000)
self.assertIn('normal', reason)
def test_empty_payload(self):
self.assertEqual(RawWebSocket._parse_close(b''), (None, ''))
class HandshakeErrorTest(unittest.TestCase):
def test_redirect_status_codes(self):
for code in (301, 302, 303, 307, 308):
self.assertTrue(WsHandshakeError(code, '').is_redirect)
for code in (0, 200, 429, 502):
self.assertFalse(WsHandshakeError(code, '').is_redirect)
if __name__ == '__main__':
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
import unittest
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
class ParseVersionTupleTest(unittest.TestCase):
def test_plain_and_prefixed_versions(self):
self.assertEqual(_parse_version_tuple('1.9.1'), (1, 9, 1))
self.assertEqual(_parse_version_tuple('v1.9.1'), (1, 9, 1))
self.assertEqual(_parse_version_tuple(' V2.0 '), (2, 0))
def test_trailing_suffixes_are_truncated_to_digits(self):
self.assertEqual(_parse_version_tuple('1.9.1rc2'), (1, 9, 1))
self.assertEqual(_parse_version_tuple('1.9.1-beta'), (1, 9, 1))
def test_empty_and_non_numeric_segments(self):
self.assertEqual(_parse_version_tuple(''), (0,))
self.assertEqual(_parse_version_tuple(None), (0,))
self.assertEqual(_parse_version_tuple('1.x.3'), (1, 0, 3))
class VersionGtTest(unittest.TestCase):
def test_newer_versions(self):
self.assertTrue(_version_gt('1.9.2', '1.9.1'))
self.assertTrue(_version_gt('1.10.0', '1.9.9'))
self.assertTrue(_version_gt('2.0', '1.9.9'))
def test_equal_and_older_versions(self):
self.assertFalse(_version_gt('1.9.1', '1.9.1'))
self.assertFalse(_version_gt('1.9.1', '1.9.2'))
self.assertFalse(_version_gt('1.9', '1.9.0'))
def test_shorter_version_is_padded_with_zeros(self):
self.assertTrue(_version_gt('1.9.1', '1.9'))
self.assertFalse(_version_gt('1.9', '1.9.1'))
class ExtractAssetsTest(unittest.TestCase):
def test_keeps_name_url_and_digest(self):
data = {'assets': [{
'name': 'TgWsProxy_windows.exe',
'browser_download_url': 'https://example.invalid/a.exe',
'digest': 'sha256:abc',
}]}
self.assertEqual(_extract_assets(data), [{
'name': 'TgWsProxy_windows.exe',
'url': 'https://example.invalid/a.exe',
'digest': 'sha256:abc',
}])
def test_drops_entries_without_name_or_url(self):
data = {'assets': [
{'name': 'a.exe'},
{'browser_download_url': 'https://example.invalid/b.exe'},
]}
self.assertEqual(_extract_assets(data), [])
def test_missing_digest_becomes_empty_string(self):
data = {'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe',
}]}
self.assertEqual(_extract_assets(data)[0]['digest'], '')
def test_empty_input(self):
self.assertEqual(_extract_assets(None), [])
self.assertEqual(_extract_assets({}), [])
if __name__ == '__main__':
unittest.main()
+10 -13
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import tkinter as tk
import customtkinter as ctk
from typing import Any, List, Optional
@@ -18,7 +18,7 @@ class CtkTooltip:
self.delay_ms = delay_ms
self.wraplength = wraplength
self._after_id: Optional[str] = None
self._tip: Optional[tk.Toplevel] = None
self._tip: Optional[ctk.CTkToplevel] = None
widget.bind("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self._hide, add="+")
widget.bind("<Button>", self._hide, add="+")
@@ -48,27 +48,24 @@ class CtkTooltip:
except Exception:
return
tw = tk.Toplevel(self.widget.winfo_toplevel())
tw = ctk.CTkToplevel(self.widget.winfo_toplevel())
tw.wm_overrideredirect(True)
try:
tw.wm_attributes("-topmost", True)
except Exception:
pass
tw.configure(bg="#2b2b2b")
lbl = tk.Label(
tw.configure(fg_color="#2b2b2b")
lbl = ctk.CTkLabel(
tw,
text=self.text,
justify="left",
wraplength=self.wraplength,
background="#2b2b2b",
foreground="#f0f0f0",
relief="flat",
borderwidth=0,
padx=10,
pady=8,
font=("Segoe UI", 10) if _is_windows() else None,
fg_color="#2b2b2b",
text_color="#f0f0f0",
corner_radius=0,
font=("Segoe UI", 14) if _is_windows() else None,
)
lbl.pack()
lbl.pack(padx=10, pady=8)
x = self.widget.winfo_rootx() + 12
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
tw.wm_geometry(f"+{x}+{y}")
+86 -31
View File
@@ -27,8 +27,12 @@ from ui.i18n import (
log = logging.getLogger('tg-mtproto-proxy')
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
def _get_doc_url(doc_name: str) -> str:
from ui.i18n import get_language
lang = get_language().value
lang_folder = "EN" if lang == "en" else "RU"
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
@@ -248,11 +252,11 @@ def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict(
font=(theme.ui_font_family, 13), corner_radius=radius,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
opts = {
"font": (theme.ui_font_family, 13), "corner_radius": radius,
"fg_color": theme.bg, "border_color": theme.field_border,
"border_width": 1, "text_color": theme.text_primary,
}
if var is not None:
opts["textvariable"] = var
if width:
@@ -351,7 +355,9 @@ class TrayConfigFormWidgets:
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
cfproxy_user_domain_enabled_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_enabled_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
@@ -367,6 +373,7 @@ def install_tray_config_form(
show_autostart: bool = False,
autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg)
@@ -400,7 +407,7 @@ def install_tray_config_form(
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md"),
webbrowser.open(_get_doc_url("Funding")),
),
).pack(side="right", padx=(0, 6))
@@ -591,7 +598,9 @@ def install_tray_config_form(
saved_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
)
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
cf_custom_cb_var = ctk.BooleanVar(
value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
)
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
@@ -601,7 +610,7 @@ def install_tray_config_form(
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
@@ -615,8 +624,6 @@ def install_tray_config_form(
def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled"
cf_domain_entry.configure(state=state)
if not cf_custom_cb_var.get():
cfproxy_user_domain_var.set("")
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry()
@@ -626,16 +633,25 @@ def install_tray_config_form(
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
cf_worker_lbl.pack(anchor="w", pady=(0, 2))
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x")
cfproxy_worker_domain_var = ctk.StringVar(
value=", ".join(coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
))
saved_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
)
cfproxy_worker_enabled_var = ctk.BooleanVar(
value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
)
cf_worker_cb = _checkbox(
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
cfproxy_worker_enabled_var,
)
cf_worker_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
cfproxy_worker_domain_var = ctk.StringVar(value=", ".join(saved_worker_domains))
cf_worker_entry = _entry(
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
height=32, radius=8,
@@ -649,13 +665,16 @@ def install_tray_config_form(
btn = _cfworker_test_btn[0]
if btn is None:
return
enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
enabled = (
cfproxy_worker_enabled_var.get()
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
)
btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test():
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
btn = _cfworker_test_btn[0]
if not domains or btn is None:
if not cfproxy_worker_enabled_var.get() or not domains or btn is None:
return
btn.configure(text=t("button.test_loading"), state="disabled")
import threading as _threading
@@ -682,20 +701,27 @@ def install_tray_config_form(
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_CFWORKER_HELP_URL),
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
).pack(side="right")
_cfworker_test_widget = ctk.CTkButton(
cf_worker_input, text=t("button.test"), width=56, height=32,
cf_worker_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cfworker_test,
)
_cfworker_test_widget.pack(side="right", padx=(0, 6))
_cfworker_test_widget.pack(side="right")
_cfworker_test_btn[0] = _cfworker_test_widget
def _sync_cfworker_entry(*_):
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
cf_worker_entry.configure(state=state)
_sync_cfworker_test_button()
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
_sync_cfworker_test_button()
_sync_cfworker_entry()
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
@@ -751,14 +777,35 @@ def install_tray_config_form(
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
if st.get("has_update") and on_update_click is not None:
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
upd_btn_row.pack(fill="x")
upd_btn_row.grid_columnconfigure(0, weight=1)
upd_btn_row.grid_columnconfigure(1, weight=1)
ctk.CTkButton(
upd_btn_row, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
ctk.CTkButton(
upd_btn_row, text=t("button.update"), height=32,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_update_click,
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
autostart_var = None
if show_autostart:
@@ -780,7 +827,9 @@ def install_tray_config_form(
adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var,
language_var=language_var,
@@ -872,8 +921,14 @@ def validate_config_form(
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_user_domain_enabled_var is not None:
new_cfg["cfproxy_user_domain_enabled"] = bool(
widgets.cfproxy_user_domain_enabled_var.get()
)
if widgets.cfproxy_user_domain_var is not None:
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
if widgets.cfproxy_worker_enabled_var is not None:
new_cfg["cfproxy_worker_enabled"] = bool(widgets.cfproxy_worker_enabled_var.get())
if widgets.cfproxy_worker_domain_var is not None:
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
if widgets.appearance_var is not None:
+4 -4
View File
@@ -25,10 +25,10 @@ class LocaleEnum(str, Enum):
return _DEFAULT_LOCALE
try:
_LOCALES_DIR = Path(__file__).resolve(strict=False).parent
except OSError:
_LOCALES_DIR = Path(os.path.realpath(__file__)).parent
_module_path = Path(__file__)
if not _module_path.is_absolute():
_module_path = Path.cwd() / _module_path
_LOCALES_DIR = _module_path.parent
_DEFAULT_LOCALE = LocaleEnum.english
_translations: Dict[str, str] = {}
+1
View File
@@ -113,6 +113,7 @@
"tray.restart": "Restart proxy",
"tray.settings": "Settings...",
"tray.logs": "Open logs",
"tray.update": "Update ({current} → {new})",
"tray.exit": "Exit",
"dialog.restart_title": "Restart?",
+1
View File
@@ -113,6 +113,7 @@
"tray.restart": "Перезапустить прокси",
"tray.settings": "Настройки...",
"tray.logs": "Открыть логи",
"tray.update": "Обновить ({current} → {new})",
"tray.exit": "Выход",
"dialog.restart_title": "Перезапустить?",
+2 -1
View File
@@ -20,10 +20,11 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"buf_kb": 256,
"pool_size": 4,
"cfproxy": True,
"cfproxy_user_domain_enabled": False,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": False,
"cfproxy_worker_domain": [],
"force_test_dc": False,
"ws_keepalive_interval": 30,
}
+35 -3
View File
@@ -196,6 +196,14 @@ def load_config() -> dict:
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if "cfproxy_user_domain_enabled" not in data:
data["cfproxy_user_domain_enabled"] = bool(
coerce_domain_list(data.get("cfproxy_user_domain"))
)
if "cfproxy_worker_enabled" not in data:
data["cfproxy_worker_enabled"] = bool(
coerce_domain_list(data.get("cfproxy_worker_domain"))
)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
cfg = data
@@ -315,6 +323,17 @@ def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
if diagnose_called:
diagnose_called()
finally:
pending = [
task for task in asyncio.all_tasks(loop)
if not task.done()
]
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(
*pending, return_exceptions=True
))
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
_async_stop = None
@@ -335,10 +354,23 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]))
cfproxy_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
)
cfproxy_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"])
)
pc.cfproxy_user_domains = (
cfproxy_user_domains
if cfg.get("cfproxy_user_domain_enabled", bool(cfproxy_user_domains))
else []
)
pc.cfproxy_worker_domains = (
cfproxy_worker_domains
if cfg.get("cfproxy_worker_enabled", bool(cfproxy_worker_domains))
else []
)
pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
return True
+63 -21
View File
@@ -34,7 +34,7 @@ try:
except ImportError:
Image = None
from proxy import get_link_host
from proxy import __version__, get_link_host
from utils.win32_theme import (
is_windows_dark_theme,
@@ -47,6 +47,9 @@ from utils.tray_common import (
quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from utils.update_check import (
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
@@ -62,6 +65,7 @@ _tray_icon: Optional[object] = None
_config: dict = {}
_exiting = False
_win_mutex_handle = None
_update_flow_lock = threading.Lock()
_ERROR_ALREADY_EXISTS = 183
@@ -146,6 +150,7 @@ def update_ctk_form(
width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100,
theme=theme,
topmost=False,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
@@ -203,7 +208,7 @@ def update_ctk_form(
btns.append(btn_upd)
btn_pg = ctk.CTkButton(
row, text=t("button.page"), width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
font=(theme.ui_font_family, 13), command=lambda: webbrowser.open(release_url or RELEASES_PAGE_URL),
)
btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg)
@@ -317,6 +322,42 @@ def _perform_update(download_url: str, set_status=None) -> None:
os._exit(0)
def _trigger_update_flow(icon=None, item=None) -> None:
"""Show the update dialog for an update already known to be available.
Reused by the tray "Update" item and the settings dialog's "Update"
button, so an update can still be started after the initial startup
prompt was skipped/closed.
"""
if not _update_flow_lock.acquire(blocking=False):
return
def _show() -> None:
try:
if _exiting:
return
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc:
log.warning("Update flow failed: %s", repr(exc))
finally:
_update_flow_lock.release()
threading.Thread(target=_show, daemon=True, name="manual-update").start()
def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True):
return
@@ -326,23 +367,12 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
if is_exiting():
return
try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update") or is_exiting():
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
if is_exiting():
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
_trigger_update_flow()
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
@@ -481,7 +511,7 @@ def _edit_config_dialog() -> None:
root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -498,6 +528,7 @@ def _edit_config_dialog() -> None:
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu,
on_update_click=_trigger_update_flow,
)
_original_appearance = ctk.get_appearance_mode()
@@ -579,7 +610,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
)
def on_done(open_tg: bool) -> None:
@@ -602,7 +633,7 @@ def _build_menu():
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host)
return pystray.Menu(
items = [
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR,
@@ -611,7 +642,18 @@ def _build_menu():
pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit),
)
]
st = get_status()
if st.get("has_update"):
items[-2:-2] = [
pystray.Menu.SEPARATOR,
pystray.MenuItem(
t("tray.update", current=__version__, new=st.get("latest") or "?"),
_trigger_update_flow,
)
]
return pystray.Menu(*items)
# entry point