mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-09-05 18:16:11 +03:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be485a3851 | |||
| f811a10918 | |||
| 55ec6a91df | |||
| b23c8f6122 | |||
| d36bb7df71 | |||
| bd4f3d080c | |||
| 492d999248 | |||
| e8ad0d6958 | |||
| 5eb7e007d3 | |||
| 1ae2f3a147 | |||
| d2b6c450eb | |||
| 1b8671b3d3 | |||
| b2a8074c59 | |||
| 0d459995f3 | |||
| b8a5634008 | |||
| de4179305c | |||
| e3958984c4 | |||
| 3e7e266176 | |||
| fba86856db | |||
| 8b05ba79ae | |||
| 9f9e1c2482 | |||
| 2496004c93 | |||
| b7ff77f550 | |||
| 2995ae7436 | |||
| 02e52da3a7 |
@@ -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
@@ -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
@@ -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
@@ -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" />
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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 ❤️
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 (10001–10003). 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)).
|
||||
@@ -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
@@ -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
|
||||
@@ -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) за информацию.
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -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
|
||||
@@ -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)).
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,8 +4,8 @@
|
||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 9, 1, 0),
|
||||
prodvers=(1, 9, 1, 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.1.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.1.0'),
|
||||
StringStruct(u'ProductVersion', u'1.10.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
@@ -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.1"
|
||||
__version__ = "1.10.1"
|
||||
|
||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||
@@ -202,6 +202,7 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||
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",
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+12
-11
@@ -14,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):
|
||||
@@ -26,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()
|
||||
@@ -50,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):
|
||||
@@ -98,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
|
||||
@@ -150,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():
|
||||
@@ -166,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)
|
||||
@@ -296,7 +297,7 @@ class _CfWorkerPool:
|
||||
|
||||
def available_domains(self, worker_domains: List[str]) -> List[str]:
|
||||
now = time.time()
|
||||
domains = list()
|
||||
domains = []
|
||||
for domain in worker_domains:
|
||||
if domain in domains:
|
||||
continue
|
||||
|
||||
+24
-7
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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] = {}
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"tray.restart": "Перезапустить прокси",
|
||||
"tray.settings": "Настройки...",
|
||||
"tray.logs": "Открыть логи",
|
||||
"tray.update": "Обновить ({current} → {new})",
|
||||
"tray.exit": "Выход",
|
||||
|
||||
"dialog.restart_title": "Перезапустить?",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user