24 Commits

Author SHA1 Message Date
Flowseal 5bc5001c4d SHA256 compare fix 2026-06-18 15:22:30 +03:00
Flowseal 2afd80825b docs update 2026-06-17 11:33:20 +03:00
Flowseal 12fafbc8f4 Version bump 2026-06-17 11:05:17 +03:00
Flowseal 5839ca2564 Portable mode 2026-06-17 11:02:04 +03:00
Flowseal e40c571009 #646 fixes 2026-06-17 10:25:45 +03:00
Konukhov Yaroslav 96e5b4b639 fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925) 2026-06-17 10:13:55 +03:00
Flowseal 13d2b1db6d #943 fixes 2026-06-17 09:54:53 +03:00
Yan a29a1a8610 Добавлена сборка Windows ARM64 в Build & Release workflow (#943) 2026-06-17 09:50:32 +03:00
partoftheworlD 94010f1481 Added support for cf worker for docker container (#996) 2026-06-17 09:45:25 +03:00
Kenyka Kenykovich 42172235c7 Исправлен fallback список CF proxy доменов (добавлена запятая) (#958) 2026-06-17 09:44:00 +03:00
Flowseal b0010af130 #924 improvements 2026-06-17 09:43:06 +03:00
Konukhov Yaroslav 784a7f659b fix: diagnose permission and bad-address bind failures on startup (#924) 2026-06-17 09:24:44 +03:00
Konukhov Yaroslav 21fe672963 fix: rotate log files instead of growing without bound (#885) (#932) 2026-06-17 09:13:32 +03:00
Flowseal ed46ecce5a version bump 2026-06-03 17:14:12 +03:00
Flowseal 9562b11101 docs 2026-06-03 17:13:47 +03:00
Flowseal dfdb993da5 shuffle cfworker domains 2026-06-03 17:09:16 +03:00
Flowseal d4f8b51326 version bump 2026-05-30 20:34:26 +03:00
Flowseal ca431633d7 Version bump 2026-05-30 20:32:11 +03:00
Flowseal ea4e8e790a Possibility to pass few cfproxy and worker domains 2026-05-30 20:30:47 +03:00
Flowseal 05d6de269b import path fixes 2026-05-30 19:39:58 +03:00
Flowseal 1c4b103df2 Pool for cloudflare worker 2026-05-30 19:34:47 +03:00
Erik 23f0e4d426 Fall back to system libcrypto when cryptography is unavailable (#894) 2026-05-30 19:31:47 +03:00
Konukhov Yaroslav 49e62ca142 perf(bridge): split MTProto packets in O(N) instead of O(N^2) (#913) 2026-05-30 19:25:56 +03:00
delewer 5915a0e1f3 docs: update images (#858) 2026-05-17 01:04:37 +03:00
29 changed files with 1017 additions and 321 deletions
+5
View File
@@ -8,3 +8,8 @@ clngqrflngqin.com
tjacxbqtj.com tjacxbqtj.com
bxaxtxmrw.com bxaxtxmrw.com
dmohrsgmohcrwb.com dmohrsgmohcrwb.com
vwbmtmoi.com
khgrre.com
ulihssf.com
tmhqsdqmfpmk.com
xwuwoqbm.com
+80 -3
View File
@@ -17,7 +17,7 @@ permissions:
contents: write contents: write
jobs: jobs:
build-windows: build-windows-x64:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -73,9 +73,85 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy name: TgWsProxy-windows-x64
path: dist/TgWsProxy_windows.exe path: dist/TgWsProxy_windows.exe
build-windows-arm64:
runs-on: windows-11-arm
env:
CRYPTOGRAPHY_VERSION: "46.0.5"
ARM64_WHEELHOUSE: wheelhouse-arm64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
architecture: arm64
cache: "pip"
- name: Restore ARM64 cryptography wheel
id: cryptography-wheel-cache
uses: actions/cache@v4
with:
path: ${{ env.ARM64_WHEELHOUSE }}
key: windows-arm64-py311-cryptography-${{ env.CRYPTOGRAPHY_VERSION }}-${{ hashFiles('pyproject.toml', '.github/workflows/build.yml') }}
- name: Install ARM64 OpenSSL
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
vcpkg install openssl:arm64-windows-static
$opensslDir = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static"
"OPENSSL_DIR=$opensslDir" >> $env:GITHUB_ENV
"OPENSSL_STATIC=1" >> $env:GITHUB_ENV
"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV
- name: Build ARM64 cryptography wheel
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
run: |
mkdir $env:ARM64_WHEELHOUSE
pip wheel --no-deps --wheel-dir $env:ARM64_WHEELHOUSE "cryptography==$env:CRYPTOGRAPHY_VERSION"
- name: Install dependencies & pyinstaller
run: pip install --find-links $env:ARM64_WHEELHOUSE . "pyinstaller==6.13.0"
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
print('Rich header not found, skipping')
raise SystemExit(0)
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
print('DanS marker not found, skipping')
raise SystemExit(0)
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-windows-arm64
path: dist/TgWsProxy_windows_arm64.exe
build-win7: build-win7:
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
@@ -439,7 +515,7 @@ jobs:
dist/TgWsProxy_linux_amd64.rpm dist/TgWsProxy_linux_amd64.rpm
release: release:
needs: [build-windows, build-win7, build-macos, build-linux] needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }} if: ${{ github.event.inputs.make_release == 'true' }}
steps: steps:
@@ -463,6 +539,7 @@ jobs:
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/ > Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
files: | files: |
dist/TgWsProxy_windows.exe dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_arm64.exe
dist/TgWsProxy_windows_7_64bit.exe dist/TgWsProxy_windows_7_64bit.exe
dist/TgWsProxy_windows_7_32bit.exe dist/TgWsProxy_windows_7_32bit.exe
dist/TgWsProxy_macos_universal.dmg dist/TgWsProxy_macos_universal.dmg
+4 -3
View File
@@ -25,7 +25,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
TG_WS_PROXY_HOST=0.0.0.0 \ TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1443 \ TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_SECRET="" \ TG_WS_PROXY_SECRET="" \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
TG_WS_PROXY_CF_WORKER=""
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \ && apt-get install -y --no-install-recommends tini ca-certificates \
@@ -42,5 +43,5 @@ USER app
EXPOSE 1443/tcp EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD [] CMD []
+2 -2
View File
@@ -47,8 +47,8 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов | | `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) | | `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare. [Подробнее](./CfProxy.md) | | `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md) | | `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом | | `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) | | `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--buf-kb` | `256` | Размер буфера в КБ | | `--buf-kb` | `256` | Размер буфера в КБ |
+7 -6
View File
@@ -35,12 +35,13 @@ tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
Все настройки задаются переменными окружения при запуске контейнера: Все настройки задаются переменными окружения при запуске контейнера:
| Переменная | Описание | По умолчанию | | Переменная | Описание | По умолчанию |
|-----------------------|------------------------------------------------|--------------------------------------| | ----------------------- | --------------------------------- | ------------------------------------- |
| `TG_WS_PROXY_HOST` | Адрес для приёма подключений | `0.0.0.0` | | `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | Порт внутри контейнера | `1443` | | `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
| `TG_WS_PROXY_SECRET` | Секретный ключ | `random` | | `TG_WS_PROXY_SECRET` | `Секретный ключ` | `random` |
| `TG_WS_PROXY_DC_IPS` | Пары «номер DC:IP» через пробел | `2:149.154.167.220 4:149.154.167.220`| | `TG_WS_PROXY_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
Пример с ручным указанием секрета: Пример с ручным указанием секрета:
+8 -6
View File
@@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<br /> <br />
<p> <p>
<img width="1729" height="910" alt="tgwsproxy" src="https://github.com/user-attachments/assets/86c230be-3286-4da9-8a30-1c781bee44e6" /> <img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
</p> </p>
</div> </div>
@@ -33,8 +33,8 @@
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы. **Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
<picture> <picture>
<source srcset="https://github.com/user-attachments/assets/17f1d15e-e1c2-41ea-a452-220d13359262" media="(prefers-color-scheme: dark)"> <source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
<img src="https://github.com/user-attachments/assets/8d595468-83a1-4e4f-bac4-9ce4a07027bd"> <img src="./images/preview-white.png">
</picture> </picture>
## Навигация ## Навигация
@@ -49,13 +49,14 @@
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md) - [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
- [Файлы конфигурации Tray-приложения](./TrayConfig.md) - [Файлы конфигурации Tray-приложения](./TrayConfig.md)
- [Установка из исходников](./BuildFromSource.md) - [Установка из исходников](./BuildFromSource.md)
- [Руководство для контрибьюторов](../CONTRIBUTING.md) - [Руководство для контрибьюторов](./CONTRIBUTING.md)
## Windows: быстрый вход ## Windows: быстрый вход
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте: Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+) - `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64) - `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32) - `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
@@ -116,7 +117,8 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy_windows.exe` - Windows 10+ x64 для `TgWsProxy_windows.exe`
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe` - Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe` - Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+ - Intel macOS 10.15+
+6 -1
View File
@@ -2,7 +2,8 @@
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте: Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+) - `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64) - `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32) - `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
@@ -42,6 +43,10 @@
- **Порт:** `1443` (или переопределенный вами) - **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов - **Secret:** из настроек или логов
## Портативный режим
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
## Установка из исходников ## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md) Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+17 -15
View File
@@ -24,7 +24,7 @@ try:
except ImportError: except ImportError:
pyperclip = None pyperclip = None
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.tg_ws_proxy import _run from proxy.tg_ws_proxy import _run
from utils.tray_common import ( from utils.tray_common import (
@@ -32,6 +32,7 @@ from utils.tray_common import (
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
) )
from utils.diagnostics import diagnose_listen_error
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
@@ -115,7 +116,7 @@ def _ask_cfworker_domain(default: str) -> Optional[str]:
value = default value = default
while True: while True:
script = ( script = (
f'set d to display dialog "{_esc("Cloudflare Worker домен (например, name.account.workers.dev):")}" ' f'set d to display dialog "{_esc("Cloudflare Worker домены через запятую (например, name.account.workers.dev):")}" '
f'default answer "{_esc(value)}" ' f'default answer "{_esc(value)}" '
f'with title "TG WS Proxy" ' f'with title "TG WS Proxy" '
f'buttons {{"Закрыть", "?", "OK"}} ' f'buttons {{"Закрыть", "?", "OK"}} '
@@ -184,13 +185,9 @@ def _run_proxy_thread() -> None:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc): msg, _ = diagnose_listen_error(exc)
_show_error( if msg:
"Не удалось запустить прокси:\n" _show_error(msg)
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -425,19 +422,24 @@ def _edit_config_dialog() -> None:
return return
cfproxy_domain = _osascript_input( cfproxy_domain = _osascript_input(
"Свой CF-домен (оставьте пустым для автоматического выбора):\n" "Свои CF-домены через запятую (оставьте пустым для автоматического выбора):\n"
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.", "DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")), ", ".join(coerce_domain_list(
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", []))
)),
) )
if cfproxy_domain is None: if cfproxy_domain is None:
return return
cfproxy_domain = cfproxy_domain.strip() cfproxy_domains = coerce_domain_list(cfproxy_domain)
cfworker_domain = _ask_cfworker_domain( cfworker_domain = _ask_cfworker_domain(
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", "")) ", ".join(coerce_domain_list(
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", []))
))
) )
if cfworker_domain is None: if cfworker_domain is None:
return return
cfworker_domains = coerce_domain_list(cfworker_domain)
new_cfg = { new_cfg = {
"host": host, "host": host,
@@ -450,8 +452,8 @@ def _edit_config_dialog() -> None:
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
"check_updates": cfg.get("check_updates", True), "check_updates": cfg.get("check_updates", True),
"cfproxy": cfproxy, "cfproxy": cfproxy,
"cfproxy_user_domain": cfproxy_domain, "cfproxy_user_domain": cfproxy_domains,
"cfproxy_worker_domain": cfworker_domain, "cfproxy_worker_domain": cfworker_domains,
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx # http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 7, 0, 0), filevers=(1, 7, 3, 0),
prodvers=(1, 7, 0, 0), prodvers=(1, 7, 3, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[ [
StringStruct(u'CompanyName', u'Flowseal'), StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'), StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.7.0.0'), StringStruct(u'FileVersion', u'1.7.3.0'),
StringStruct(u'InternalName', u'TgWsProxy'), StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'), StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'), StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'), StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.7.0.0'), StringStruct(u'ProductVersion', u'1.7.3.0'),
] ]
) )
] ]
+3 -3
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener from .utils import get_link_host, build_github_opener
__version__ = "1.7.0" __version__ = "1.7.3"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+130
View File
@@ -0,0 +1,130 @@
"""
AES-CTR shim.
Prefers `cryptography` if available (desktop / Docker). Falls back to a
ctypes wrapper over the system OpenSSL `libcrypto` for environments where
installing `cryptography` is painful (Entware on routers, embedded boxes
without a Rust toolchain). The public surface mimics the small subset of
`cryptography.hazmat.primitives.ciphers` that this project actually uses:
Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor().update(data)
"""
from __future__ import annotations
try:
from cryptography.hazmat.primitives.ciphers import ( # noqa: F401
Cipher, algorithms, modes,
)
except ImportError:
import ctypes
import ctypes.util
def _load_libcrypto():
name = ctypes.util.find_library("crypto")
candidates = []
if name:
candidates.append(name)
candidates += [
"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so.1.0.0",
"libcrypto.so", "/opt/lib/libcrypto.so",
"/opt/lib/libcrypto.so.1.1", "/opt/lib/libcrypto.so.3",
]
last_err = None
for c in candidates:
try:
return ctypes.CDLL(c)
except OSError as e:
last_err = e
raise RuntimeError(
"libcrypto not found; install openssl-util or "
"`opkg install libopenssl`. Last error: %r" % last_err
)
_libcrypto = _load_libcrypto()
_libcrypto.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
_libcrypto.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
_libcrypto.EVP_aes_128_ctr.restype = ctypes.c_void_p
_libcrypto.EVP_aes_192_ctr.restype = ctypes.c_void_p
_libcrypto.EVP_aes_256_ctr.restype = ctypes.c_void_p
_libcrypto.EVP_EncryptInit_ex.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_char_p, ctypes.c_char_p,
]
_libcrypto.EVP_EncryptInit_ex.restype = ctypes.c_int
_libcrypto.EVP_EncryptUpdate.argtypes = [
ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
ctypes.c_char_p, ctypes.c_int,
]
_libcrypto.EVP_EncryptUpdate.restype = ctypes.c_int
_EVP_BY_KEY = {
16: _libcrypto.EVP_aes_128_ctr,
24: _libcrypto.EVP_aes_192_ctr,
32: _libcrypto.EVP_aes_256_ctr,
}
class algorithms:
class AES:
__slots__ = ("key",)
def __init__(self, key: bytes):
if len(key) not in _EVP_BY_KEY:
raise ValueError("AES key must be 16/24/32 bytes")
self.key = bytes(key)
class modes:
class CTR:
__slots__ = ("iv",)
def __init__(self, iv: bytes):
if len(iv) != 16:
raise ValueError("CTR IV must be 16 bytes")
self.iv = bytes(iv)
class _CtrStream:
__slots__ = ("_ctx",)
def __init__(self, key: bytes, iv: bytes):
ctx = _libcrypto.EVP_CIPHER_CTX_new()
if not ctx:
raise RuntimeError("EVP_CIPHER_CTX_new failed")
self._ctx = ctx
evp = _EVP_BY_KEY[len(key)]()
if _libcrypto.EVP_EncryptInit_ex(ctx, evp, None, key, iv) != 1:
_libcrypto.EVP_CIPHER_CTX_free(ctx)
self._ctx = None
raise RuntimeError("EVP_EncryptInit_ex failed")
def update(self, data: bytes) -> bytes:
if not data:
return b""
outlen = ctypes.c_int(0)
buf = ctypes.create_string_buffer(len(data) + 16)
if _libcrypto.EVP_EncryptUpdate(
self._ctx, buf, ctypes.byref(outlen), bytes(data), len(data)
) != 1:
raise RuntimeError("EVP_EncryptUpdate failed")
return buf.raw[:outlen.value]
def __del__(self):
ctx = getattr(self, "_ctx", None)
if ctx:
_libcrypto.EVP_CIPHER_CTX_free(ctx)
self._ctx = None
class Cipher:
__slots__ = ("_key", "_iv")
def __init__(self, algorithm, mode):
if not isinstance(algorithm, algorithms.AES):
raise TypeError("only AES is supported")
if not isinstance(mode, modes.CTR):
raise TypeError("only CTR mode is supported")
self._key = algorithm.key
self._iv = mode.iv
def encryptor(self) -> _CtrStream:
return _CtrStream(self._key, self._iv)
# CTR is symmetric — decryption == encryption with the same keystream.
decryptor = encryptor
+91 -58
View File
@@ -1,9 +1,9 @@
import asyncio import asyncio
import logging import logging
import struct import struct
import random
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from typing import List, Optional
from typing import Dict, List, Optional
from urllib.parse import urlencode from urllib.parse import urlencode
from .utils import * from .utils import *
@@ -11,20 +11,14 @@ from .stats import stats
from .balancer import balancer from .balancer import balancer
from .config import proxy_config from .config import proxy_config
from .raw_websocket import RawWebSocket from .raw_websocket import RawWebSocket
from .pool import cf_worker_pool
from ._aes import Cipher, algorithms, modes
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
_st_I_le = struct.Struct('<I') _st_I_le = struct.Struct('<I')
ZERO_64 = b'\x00' * 64 ZERO_64 = b'\x00' * 64
DC_DEFAULT_IPS: Dict[int, str] = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100'
}
class CryptoCtx: class CryptoCtx:
@@ -64,19 +58,27 @@ class MsgSplitter:
self._plain_buf.extend(self._dec.update(chunk)) self._plain_buf.extend(self._dec.update(chunk))
parts = [] parts = []
while self._cipher_buf: offset = 0
packet_len = self._next_packet_len() buf_len = len(self._cipher_buf)
# Walk the buffer with an offset instead of deleting each packet from
# the front. Front-deletion on a bytearray shifts the remaining bytes,
# so a chunk holding many small packets degrades to O(N^2); a single
# trailing del keeps splitting O(N).
while offset < buf_len:
packet_len = self._next_packet_len(offset, buf_len - offset)
if packet_len is None: if packet_len is None:
break break
if packet_len <= 0: if packet_len <= 0:
parts.append(bytes(self._cipher_buf)) parts.append(bytes(self._cipher_buf[offset:]))
self._cipher_buf.clear() offset = buf_len
self._plain_buf.clear()
self._disabled = True self._disabled = True
break break
parts.append(bytes(self._cipher_buf[:packet_len])) parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
del self._cipher_buf[:packet_len] offset += packet_len
del self._plain_buf[:packet_len]
if offset:
del self._cipher_buf[:offset]
del self._plain_buf[:offset]
return parts return parts
def flush(self) -> List[bytes]: def flush(self) -> List[bytes]:
@@ -87,22 +89,23 @@ class MsgSplitter:
self._plain_buf.clear() self._plain_buf.clear()
return [tail] return [tail]
def _next_packet_len(self) -> Optional[int]: def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
if not self._plain_buf: if avail <= 0:
return None return None
if self._proto == PROTO_ABRIDGED_INT: if self._proto == PROTO_ABRIDGED_INT:
return self._next_abridged_len() return self._next_abridged_len(offset, avail)
if self._proto in (PROTO_INTERMEDIATE_INT, if self._proto in (PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT): PROTO_PADDED_INTERMEDIATE_INT):
return self._next_intermediate_len() return self._next_intermediate_len(offset, avail)
return 0 return 0
def _next_abridged_len(self) -> Optional[int]: def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
first = self._plain_buf[0] first = self._plain_buf[offset]
if first in (0x7F, 0xFF): if first in (0x7F, 0xFF):
if len(self._plain_buf) < 4: if avail < 4:
return None return None
payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4 payload_len = int.from_bytes(
self._plain_buf[offset + 1:offset + 4], 'little') * 4
header_len = 4 header_len = 4
else: else:
payload_len = (first & 0x7F) * 4 payload_len = (first & 0x7F) * 4
@@ -110,33 +113,32 @@ class MsgSplitter:
if payload_len <= 0: if payload_len <= 0:
return 0 return 0
packet_len = header_len + payload_len packet_len = header_len + payload_len
if len(self._plain_buf) < packet_len: if avail < packet_len:
return None return None
return packet_len return packet_len
def _next_intermediate_len(self) -> Optional[int]: def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
if len(self._plain_buf) < 4: if avail < 4:
return None return None
payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
if payload_len <= 0: if payload_len <= 0:
return 0 return 0
packet_len = 4 + payload_len packet_len = 4 + payload_len
if len(self._plain_buf) < packet_len: if avail < packet_len:
return None return None
return packet_len return packet_len
async def do_fallback(reader, writer, relay_init, label, async def do_fallback(reader, writer, relay_init, label,
dc: int, is_media: bool, media_tag: str, dc: int, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None): ctx: CryptoCtx, splitter=None):
fallback_dst = DC_DEFAULT_IPS.get(dc) fallback_dst = DC_DEFAULT_IPS.get(dc)
use_cf = proxy_config.fallback_cfproxy use_cf = proxy_config.fallback_cfproxy
worker_domain = proxy_config.cfproxy_worker_domain worker_domains = proxy_config.cfproxy_worker_domains
methods: List[str] = [] methods: List[str] = []
if worker_domain and fallback_dst: if worker_domains and fallback_dst:
methods.append('cf_worker') methods.append('cf_worker')
if use_cf: if use_cf:
methods.append('cf') methods.append('cf')
@@ -175,34 +177,42 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
fallback_dst: str, fallback_dst: str,
splitter=None): splitter=None):
media_tag = ' media' if is_media else '' media_tag = ' media' if is_media else ''
worker_domain = proxy_config.cfproxy_worker_domain worker_domains = proxy_config.cfproxy_worker_domains
if not worker_domain: if not worker_domains:
return False return False
random.shuffle(worker_domains)
query = urlencode({ for worker_domain in worker_domains:
'dst': fallback_dst, ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
'dc': str(dc), if ws:
'media': '1' if is_media else '0', log.info("[%s] DC%d%s -> CF worker pool hit for %s",
}) label, dc, media_tag, fallback_dst)
path = f'/apiws?{query}' else:
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
log.info("[%s] DC%d%s -> trying CF worker for %s", log.info("[%s] DC%d%s -> trying CF worker %s for %s",
label, dc, media_tag, fallback_dst) label, dc, media_tag, worker_domain, fallback_dst)
try: try:
ws = await RawWebSocket.connect(worker_domain, worker_domain, ws = await RawWebSocket.connect(worker_domain, worker_domain,
timeout=10.0, path=path) timeout=10.0, path=path)
except Exception as exc: except Exception as exc:
log.warning("[%s] DC%d%s CF worker failed: %s", log.warning("[%s] DC%d%s CF worker %s failed: %s",
label, dc, media_tag, repr(exc)) label, dc, media_tag, worker_domain, repr(exc))
return False continue
stats.connections_cfproxy += 1 stats.connections_cfproxy += 1
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, ctx, await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
splitter=splitter) splitter=splitter)
return True return True
return False
async def _cfproxy_fallback(reader, writer, relay_init, label, async def _cfproxy_fallback(reader, writer, relay_init, label,
@@ -256,6 +266,26 @@ async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: Crypt
return True return True
async def _ws_keepalive(ws, interval: float):
"""Send periodic WS PING frames to keep the upstream flow warm.
A non-positive interval disables keepalive. The loop exits on send
failure so a dead upstream is detected promptly instead of lingering
until the next client packet (see issue #646).
"""
if interval <= 0:
return
interval = max(1.0, interval) # reasonable minimum
try:
while True:
await asyncio.sleep(interval)
await ws.send_ping()
except (asyncio.CancelledError, ConnectionError, OSError):
return
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
ctx: CryptoCtx, ctx: CryptoCtx,
dc=None, is_media=False, dc=None, is_media=False,
@@ -327,12 +357,15 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
tasks = [asyncio.create_task(tcp_to_ws()), tasks = [asyncio.create_task(tcp_to_ws()),
asyncio.create_task(ws_to_tcp())] asyncio.create_task(ws_to_tcp())]
keepalive = asyncio.create_task(
_ws_keepalive(ws, proxy_config.ws_keepalive_interval))
try: try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally: finally:
keepalive.cancel()
for t in tasks: for t in tasks:
t.cancel() t.cancel()
for t in tasks: for t in (*tasks, keepalive):
try: try:
await t await t
except BaseException: except BaseException:
+35 -5
View File
@@ -29,7 +29,12 @@ _CFPROXY_ENC: List[str] = [
'clngqrflngqin.com', 'clngqrflngqin.com',
'tjacxbqtj.com', 'tjacxbqtj.com',
'bxaxtxmrw.com', 'bxaxtxmrw.com',
'dmohrsgmohcrwb.com' 'dmohrsgmohcrwb.com',
'vwbmtmoi.com',
'khgrre.com',
'ulihssf.com',
'tmhqsdqmfpmk.com',
'xwuwoqbm.com'
] ]
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107)) _S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
@@ -58,15 +63,40 @@ class ProxyConfig:
buffer_size: int = 256 * 1024 buffer_size: int = 256 * 1024
pool_size: int = 4 pool_size: int = 4
fallback_cfproxy: bool = True fallback_cfproxy: bool = True
cfproxy_user_domain: str = '' cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_worker_domain: str = '' cfproxy_worker_domains: List[str] = field(default_factory=list)
fake_tls_domain: str = '' fake_tls_domain: str = ''
proxy_protocol: bool = False proxy_protocol: bool = False
ws_keepalive_interval: float = 30.0
proxy_config = ProxyConfig() proxy_config = ProxyConfig()
def coerce_domain_list(value) -> List[str]:
if isinstance(value, str):
items = value.replace(',', ' ').replace(';', ' ').split()
elif isinstance(value, (list, tuple)):
items: List[str] = []
for entry in value:
if isinstance(entry, str):
items.extend(entry.replace(',', ' ').replace(';', ' ').split())
else:
return []
seen = set()
result: List[str] = []
for item in items:
item = item.strip()
if not item:
continue
key = item.lower()
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def _fetch_cfproxy_domain_list() -> List[str]: def _fetch_cfproxy_domain_list() -> List[str]:
try: try:
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)), req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
@@ -120,7 +150,7 @@ def _normalize_domain_pool(domains: List[str]) -> List[str]:
def refresh_cfproxy_domains() -> None: def refresh_cfproxy_domains() -> None:
if proxy_config.cfproxy_user_domain: if proxy_config.cfproxy_user_domains:
return return
fetched = _fetch_cfproxy_domain_list() fetched = _fetch_cfproxy_domain_list()
@@ -175,4 +205,4 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
except (ValueError, OSError): except (ValueError, OSError):
raise ValueError(f"Invalid --dc-ip {entry!r}") raise ValueError(f"Invalid --dc-ip {entry!r}")
dc_redirects[dc_n] = ip_s dc_redirects[dc_n] = ip_s
return dc_redirects return dc_redirects
+214
View File
@@ -0,0 +1,214 @@
import asyncio
import logging
import time
from collections import deque
from urllib.parse import urlencode
from typing import Dict, List, Optional, Tuple, Set
from .raw_websocket import RawWebSocket, WsHandshakeError
from .stats import stats
from .config import proxy_config
from .utils import ws_domains, DC_DEFAULT_IPS
log = logging.getLogger('tg-mtproto-proxy')
class _WsPool:
WS_POOL_MAX_AGE = 120.0
def __init__(self):
self._idle: Dict[Tuple[int, bool], deque] = {}
self._refilling: Set[Tuple[int, bool]] = set()
async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str]
) -> Optional[RawWebSocket]:
key = (dc, is_media)
now = time.monotonic()
bucket = self._idle.get(key)
if bucket is None:
bucket = deque()
self._idle[key] = bucket
while bucket:
ws, created = bucket.popleft()
age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws))
continue
stats.pool_hits += 1
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains)
return ws
stats.pool_misses += 1
self._schedule_refill(key, target_ip, domains)
return None
def _schedule_refill(self, key, target_ip, domains):
if key in self._refilling:
return
self._refilling.add(key)
asyncio.create_task(self._refill(key, target_ip, domains))
async def _refill(self, key, target_ip, domains):
dc, is_media = key
try:
bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket)
if needed <= 0:
return
tasks = [asyncio.create_task(
self._connect_one(target_ip, domains))
for _ in range(needed)]
for t in tasks:
try:
ws = await t
if ws:
bucket.append((ws, time.monotonic()))
except Exception:
pass
log.debug("WS pool refilled DC%d%s: %d ready",
dc, 'm' if is_media else '', len(bucket))
finally:
self._refilling.discard(key)
@staticmethod
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
for domain in domains:
try:
return await RawWebSocket.connect(
target_ip, domain, timeout=8)
except WsHandshakeError as exc:
if exc.is_redirect:
continue
return None
except Exception:
return None
return None
@staticmethod
async def _quiet_close(ws):
try:
await ws.close()
except Exception:
pass
async def warmup(self):
for dc, target_ip in proxy_config.dc_redirects.items():
if target_ip is None:
continue
for is_media in (False, True):
domains = ws_domains(dc, is_media)
self._schedule_refill((dc, is_media), target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
def reset(self):
self._idle.clear()
self._refilling.clear()
class _CfWorkerPool:
WS_POOL_MAX_AGE = 120.0
def __init__(self):
self._idle: Dict[Tuple[int, str], deque] = {}
self._refilling: Set[Tuple[int, str]] = set()
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
now = time.monotonic()
key = (dc, worker_domain)
bucket = self._idle.get(key)
if bucket is None:
bucket = deque()
self._idle[key] = bucket
while bucket:
ws, created = bucket.popleft()
age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws))
continue
stats.cf_pool_hits += 1
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
dc, age, len(bucket))
self._schedule_refill(key, fallback_dst)
return ws
stats.cf_pool_misses += 1
self._schedule_refill(key, fallback_dst)
return None
def _schedule_refill(self, key, fallback_dst):
if key in self._refilling:
return
self._refilling.add(key)
asyncio.create_task(self._refill(key, fallback_dst))
async def _refill(self, key, fallback_dst):
dc, worker_domain = key
try:
bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket)
if needed <= 0:
return
tasks = [asyncio.create_task(
self._connect_one(worker_domain, fallback_dst, dc))
for _ in range(needed)]
for t in tasks:
try:
ws = await t
if ws:
bucket.append((ws, time.monotonic()))
except Exception:
pass
log.debug("CF worker pool refilled DC%d: %d ready",
dc, len(bucket))
finally:
self._refilling.discard(key)
@staticmethod
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
query = urlencode({
'dst': fallback_dst,
'dc': str(dc),
})
path = f'/apiws?{query}'
try:
return await RawWebSocket.connect(
worker_domain, worker_domain, timeout=8, path=path)
except Exception:
return None
@staticmethod
async def _quiet_close(ws):
try:
await ws.close()
except Exception:
pass
async def warmup(self):
cf_fallbacks = {
dc: ip for dc, ip in DC_DEFAULT_IPS.items()
if dc not in proxy_config.dc_redirects
}
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
return
for worker_domain in proxy_config.cfproxy_worker_domains:
for dc, fallback_dst in cf_fallbacks.items():
self._schedule_refill((dc, worker_domain), fallback_dst)
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
def reset(self):
self._idle.clear()
self._refilling.clear()
ws_pool = _WsPool()
cf_worker_pool = _CfWorkerPool()
+7
View File
@@ -154,6 +154,13 @@ class RawWebSocket:
self._build_frame(self.OP_BINARY, part, mask=True)) self._build_frame(self.OP_BINARY, part, mask=True))
await self.writer.drain() await self.writer.drain()
async def send_ping(self, payload: bytes = b''):
if self._closed:
raise ConnectionError("WebSocket closed")
frame = self._build_frame(self.OP_PING, payload, mask=True)
self.writer.write(frame)
await self.writer.drain()
async def recv(self) -> Optional[bytes]: async def recv(self) -> Optional[bytes]:
while not self._closed: while not self._closed:
opcode, payload = await self._read_frame() opcode, payload = await self._read_frame()
+6
View File
@@ -14,11 +14,16 @@ class _Stats:
self.bytes_down = 0 self.bytes_down = 0
self.pool_hits = 0 self.pool_hits = 0
self.pool_misses = 0 self.pool_misses = 0
self.cf_pool_hits = 0
self.cf_pool_misses = 0
def summary(self) -> str: def summary(self) -> str:
pool_total = self.pool_hits + self.pool_misses pool_total = self.pool_hits + self.pool_misses
pool_s = (f"{self.pool_hits}/{pool_total}" pool_s = (f"{self.pool_hits}/{pool_total}"
if pool_total else "n/a") if pool_total else "n/a")
cf_pool_total = self.cf_pool_hits + self.cf_pool_misses
cf_pool_s = (f"{self.cf_pool_hits}/{cf_pool_total}"
if cf_pool_total else "n/a")
return (f"total={self.connections_total} " return (f"total={self.connections_total} "
f"active={self.connections_active} " f"active={self.connections_active} "
f"ws={self.connections_ws} " f"ws={self.connections_ws} "
@@ -28,6 +33,7 @@ class _Stats:
f"masked={self.connections_masked} " f"masked={self.connections_masked} "
f"err={self.ws_errors} " f"err={self.ws_errors} "
f"pool={pool_s} " f"pool={pool_s} "
f"cf_pool={cf_pool_s} "
f"up={human_bytes(self.bytes_up)} " f"up={human_bytes(self.bytes_up)} "
f"down={human_bytes(self.bytes_down)}") f"down={human_bytes(self.bytes_down)}")
+35 -129
View File
@@ -11,10 +11,8 @@ import logging
import logging.handlers import logging.handlers
import socket as _socket import socket as _socket
from collections import deque from typing import Dict, Optional, Set, Tuple
from typing import Dict, List, Optional, Set, Tuple
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
if __name__ == '__main__' and (__package__ is None or __package__ == ''): if __name__ == '__main__' and (__package__ is None or __package__ == ''):
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -24,11 +22,13 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''):
from .utils import * from .utils import *
from .stats import stats from .stats import stats
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, coerce_domain_list
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
from .balancer import balancer from .balancer import balancer
from .pool import ws_pool, cf_worker_pool
from ._aes import Cipher, algorithms, modes
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
@@ -100,112 +100,8 @@ def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes:
return bytes(result) return bytes(result)
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']
class _WsPool:
WS_POOL_MAX_AGE = 120.0
def __init__(self):
self._idle: Dict[Tuple[int, bool], deque] = {}
self._refilling: Set[Tuple[int, bool]] = set()
async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str]
) -> Optional[RawWebSocket]:
key = (dc, is_media)
now = time.monotonic()
bucket = self._idle.get(key)
if bucket is None:
bucket = deque()
self._idle[key] = bucket
while bucket:
ws, created = bucket.popleft()
age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws))
continue
stats.pool_hits += 1
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains)
return ws
stats.pool_misses += 1
self._schedule_refill(key, target_ip, domains)
return None
def _schedule_refill(self, key, target_ip, domains):
if key in self._refilling:
return
self._refilling.add(key)
asyncio.create_task(self._refill(key, target_ip, domains))
async def _refill(self, key, target_ip, domains):
dc, is_media = key
try:
bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket)
if needed <= 0:
return
tasks = [asyncio.create_task(
self._connect_one(target_ip, domains))
for _ in range(needed)]
for t in tasks:
try:
ws = await t
if ws:
bucket.append((ws, time.monotonic()))
except Exception:
pass
log.debug("WS pool refilled DC%d%s: %d ready",
dc, 'm' if is_media else '', len(bucket))
finally:
self._refilling.discard(key)
@staticmethod
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
for domain in domains:
try:
return await RawWebSocket.connect(
target_ip, domain, timeout=8)
except WsHandshakeError as exc:
if exc.is_redirect:
continue
return None
except Exception:
return None
return None
@staticmethod
async def _quiet_close(ws):
try:
await ws.close()
except Exception:
pass
async def warmup(self, dc_redirects: Dict[int, str]):
for dc, target_ip in dc_redirects.items():
if target_ip is None:
continue
for is_media in (False, True):
domains = _ws_domains(dc, is_media)
self._schedule_refill((dc, is_media), target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(dc_redirects))
def reset(self):
self._idle.clear()
self._refilling.clear()
_ws_pool = _WsPool()
async def _read_client_init(reader, writer, secret, label, masking): async def _read_client_init(reader, writer, secret, label, masking):
if proxy_config.proxy_protocol: if proxy_config.proxy_protocol:
@@ -420,13 +316,13 @@ async def _handle_client(reader, writer, secret: bytes):
fail_until = dc_fail_until.get(dc_key, 0) fail_until = dc_fail_until.get(dc_key, 0)
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0 ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
domains = _ws_domains(dc, is_media) domains = ws_domains(dc, is_media)
target = proxy_config.dc_redirects[dc] target = proxy_config.dc_redirects[dc]
ws = None ws = None
ws_failed_redirect = False ws_failed_redirect = False
all_redirects = True all_redirects = True
ws = await _ws_pool.get(dc, is_media, target, domains) ws = await ws_pool.get(dc, is_media, target, domains)
if ws: if ws:
log.info("[%s] DC%d%s -> pool hit via %s", log.info("[%s] DC%d%s -> pool hit via %s",
label, dc, media_tag, target) label, dc, media_tag, target)
@@ -536,15 +432,16 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
global _server_instance, _server_stop_event global _server_instance, _server_stop_event
_server_stop_event = stop_event _server_stop_event = stop_event
_ws_pool.reset() ws_pool.reset()
cf_worker_pool.reset()
ws_blacklist.clear() ws_blacklist.clear()
dc_fail_until.clear() dc_fail_until.clear()
_client_tasks.clear() _client_tasks.clear()
if proxy_config.fallback_cfproxy: if proxy_config.fallback_cfproxy:
user = proxy_config.cfproxy_user_domain user = proxy_config.cfproxy_user_domains
if user: if user:
balancer.update_domains_list([user]) balancer.update_domains_list(user)
else: else:
start_cfproxy_domain_refresh() start_cfproxy_domain_refresh()
@@ -587,11 +484,11 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
ip = proxy_config.dc_redirects.get(dc) ip = proxy_config.dc_redirects.get(dc)
log.info(" DC%d: %s", dc, ip) log.info(" DC%d: %s", dc, ip)
if proxy_config.fallback_cfproxy: if proxy_config.fallback_cfproxy:
user_domain = "user" if proxy_config.cfproxy_user_domain else "auto" user_domain = "user" if proxy_config.cfproxy_user_domains else "auto"
log.info(" CF proxy: enabled (%s)", user_domain) log.info(" CF proxy: enabled (%s)", user_domain)
if proxy_config.cfproxy_worker_domain: if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)", log.info(" CF worker: enabled (%s)",
proxy_config.cfproxy_worker_domain) ", ".join(proxy_config.cfproxy_worker_domains))
log.info("=" * 60) log.info("=" * 60)
log.info(" Connect:") log.info(" Connect:")
if ftls: if ftls:
@@ -611,7 +508,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
log_stats_task = asyncio.create_task(log_stats()) log_stats_task = asyncio.create_task(log_stats())
await _ws_pool.warmup(proxy_config.dc_redirects) await ws_pool.warmup()
await cf_worker_pool.warmup()
try: try:
async with server: async with server:
@@ -670,19 +568,22 @@ def main():
help='Log to file with rotation (default: stderr only)') help='Log to file with rotation (default: stderr only)')
ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB',
help='Max log file size in MB before rotation (default 5)') help='Max log file size in MB before rotation (default 5)')
ap.add_argument('--log-backups', type=int, default=0, metavar='N', ap.add_argument('--log-backups', type=int, default=1, metavar='N',
help='Number of rotated log files to keep (default 0)') help='Number of rotated log files to keep (min 1; '
'rotation needs at least one backup to bound size)')
ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', ap.add_argument('--buf-kb', type=int, default=256, metavar='KB',
help='Socket send/recv buffer size in KB (default 256)') help='Socket send/recv buffer size in KB (default 256)')
ap.add_argument('--pool-size', type=int, default=4, metavar='N', ap.add_argument('--pool-size', type=int, default=4, metavar='N',
help='WS connection pool size per DC (default 4, min 0)') help='WS connection pool size per DC (default 4, min 0)')
ap.add_argument('--cfproxy-domain', type=str, default='', ap.add_argument('--cfproxy-domain', action='append', default=None,
metavar='DOMAIN', metavar='DOMAIN',
help='User defined Cloudflare-proxied domain for WS fallback') help='User defined Cloudflare-proxied domain for WS fallback '
ap.add_argument('--cfproxy-worker-domain', type=str, default='', '(repeatable for multiple domains)')
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
metavar='DOMAIN', metavar='DOMAIN',
help='Cloudflare Worker domain for WS fallback ' help='Cloudflare Worker domain for WS fallback '
'(tried before other fallback methods)') '(tried before other fallback methods, '
'repeatable for multiple domains)')
ap.add_argument('--no-cfproxy', action='store_true', ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback') help='Disable Cloudflare proxy fallback')
ap.add_argument('--fake-tls-domain', type=str, default='', ap.add_argument('--fake-tls-domain', type=str, default='',
@@ -692,6 +593,10 @@ def main():
ap.add_argument('--proxy-protocol', action='store_true', ap.add_argument('--proxy-protocol', action='store_true',
help='Accept PROXY protocol v1 header ' help='Accept PROXY protocol v1 header '
'(for use behind nginx/haproxy with proxy_protocol on)') '(for use behind nginx/haproxy with proxy_protocol on)')
ap.add_argument('--ws-keepalive', type=float, default=30.0, metavar='SEC',
help='Seconds between WebSocket keepalive PINGs to the '
'upstream (default 30, 0 to disable). Keeps idle '
'sessions alive through NAT/firewall timeouts.')
args = ap.parse_args() args = ap.parse_args()
if not args.dc_ip: if not args.dc_ip:
@@ -724,10 +629,11 @@ def main():
proxy_config.buffer_size = max(4, args.buf_kb) * 1024 proxy_config.buffer_size = max(4, args.buf_kb) * 1024
proxy_config.pool_size = max(0, args.pool_size) proxy_config.pool_size = max(0, args.pool_size)
proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.cfproxy_user_domain = args.cfproxy_domain.strip() proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
proxy_config.cfproxy_worker_domain = args.cfproxy_worker_domain.strip() proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.fake_tls_domain = args.fake_tls_domain.strip() proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol proxy_config.proxy_protocol = args.proxy_protocol
proxy_config.ws_keepalive_interval = max(0, args.ws_keepalive)
log_level = logging.DEBUG if args.verbose else logging.INFO log_level = logging.DEBUG if args.verbose else logging.INFO
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
@@ -740,11 +646,11 @@ def main():
root.addHandler(console) root.addHandler(console)
if args.log_file: if args.log_file:
fh = logging.handlers.RotatingFileHandler( from utils.logging_setup import build_log_handler
fh = build_log_handler(
args.log_file, args.log_file,
maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)), log_max_mb=args.log_max_mb,
backupCount=max(0, args.log_backups), backups=args.log_backups,
encoding='utf-8',
) )
fh.setFormatter(log_fmt) fh.setFormatter(log_fmt)
root.addHandler(fh) root.addHandler(fh)
+18 -1
View File
@@ -2,7 +2,7 @@ import socket as _socket
import urllib.request import urllib.request
import http.client import http.client
from typing import Optional, Dict from typing import Optional, Dict, List
from urllib.request import Request from urllib.request import Request
@@ -34,6 +34,23 @@ _GITHUB_IPS: Dict[str, str] = {
"raw.githubusercontent.com": "185.199.109.133", "raw.githubusercontent.com": "185.199.109.133",
} }
DC_DEFAULT_IPS: Dict[int, str] = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100'
}
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']
def human_bytes(n: int) -> str: def human_bytes(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'): for unit in ('B', 'KB', 'MB', 'GB'):
+87 -24
View File
@@ -6,7 +6,7 @@ import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from proxy import __version__, get_link_host, parse_dc_ip_list from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
from proxy.balancer import balancer from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status from utils.update_check import RELEASES_PAGE_URL, get_status
@@ -59,15 +59,17 @@ _TIP_CFPROXY = (
"Использовать Cloudflare прокси для недоступных датацентров" "Использовать Cloudflare прокси для недоступных датацентров"
) )
_TIP_CFPROXY_DOMAIN = ( _TIP_CFPROXY_DOMAIN = (
"Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n" "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\n"
"Если не указан — выбирается автоматически из поддерживаемых доменов" "Несколько доменов указывайте через запятую.\n"
"Если не указаны — выбираются автоматически из поддерживаемых доменов"
) )
_TIP_CFPROXY_USER_DOMAIN_CB = ( _TIP_CFPROXY_USER_DOMAIN_CB = (
"Указать свой домен вместо автоматического выбора" "Указать свои домены вместо автоматического выбора"
) )
_TIP_CFWORKER_DOMAIN = ( _TIP_CFWORKER_DOMAIN = (
"Домен Cloudflare Worker (например, name.account.workers.dev).\n" "Домены Cloudflare Worker (например, name.account.workers.dev).\n"
"Прокси передает через него подключение к Telegram DC по IP" "Несколько доменов указывайте через запятую.\n"
"Прокси передает через них подключение к Telegram DC по IP"
) )
_TIP_SAVE = "Сохранить настройки" _TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений" _TIP_CANCEL = "Закрыть окно без сохранения изменений"
@@ -149,6 +151,14 @@ def _run_cfworker_connectivity_test(domain: str) -> dict:
return _run_connectivity_test(cases) return _run_connectivity_test(cases)
def _run_cfproxy_multi_test(domains: list) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
def _run_cfworker_multi_test(domains: list) -> dict:
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
def _run_cfproxy_auto_test(domains: list) -> tuple: def _run_cfproxy_auto_test(domains: list) -> tuple:
merged: dict = {} merged: dict = {}
best_domain = None best_domain = None
@@ -207,6 +217,52 @@ def _show_connectivity_results(title_base: str, results: dict,
_mb.showinfo(title, msg, parent=root) _mb.showinfo(title, msg, parent=root)
root.destroy() root.destroy()
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC') -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
total = len(_CFPROXY_TEST_DCS)
all_ok = True
any_ok = False
blocks = []
for domain, results in per_domain.items():
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
any_ok = True
blocks.append(f"\u2713 {domain}: все {total} серверов доступны")
elif not ok:
all_ok = False
blocks.append(f"\u2717 {domain}: недоступен")
else:
all_ok = False
any_ok = True
blocks.append(
f"~ {domain}: работают "
f"{', '.join(f'{label_prefix}{dc}' for dc in ok)}; "
f"недоступны "
f"{', '.join(f'{label_prefix}{dc}' for dc, _ in fail)}"
)
if all_ok:
title = f"{title_base}: всё работает"
elif any_ok:
title = f"{title_base}: частично работает"
else:
title = f"{title_base}: недоступен"
msg = "\n\n".join(blocks)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
_INNER_W = 396 _INNER_W = 396
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"] _APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"]
@@ -450,20 +506,23 @@ def install_tray_config_form(
_cf_test_btn = [None] _cf_test_btn = [None]
def _on_cf_test(): def _on_cf_test():
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else "" user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
btn = _cf_test_btn[0] btn = _cf_test_btn[0]
if btn: if btn:
btn.configure(text="...", state="disabled") btn.configure(text="...", state="disabled")
import threading as _threading import threading as _threading
if user_domain: if user_domains:
def _worker(): def _worker():
try: try:
res = _run_cfproxy_connectivity_test(user_domain) per = _run_cfproxy_multi_test(user_domains)
if btn: if btn:
btn.after( btn.after(
0, 0,
lambda: _show_connectivity_results( lambda: _show_multi_connectivity_results(
"CF-прокси", res, domain=user_domain, label_prefix='kws', "CF-прокси", per, label_prefix='kws',
), ),
) )
except Exception as exc: except Exception as exc:
@@ -508,8 +567,10 @@ def install_tray_config_form(
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent") cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x") cf_custom_row.pack(fill="x")
saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) saved_user_domains = coerce_domain_list(
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain)) 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 = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var) cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10)) cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB) attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
@@ -522,7 +583,7 @@ def install_tray_config_form(
command=lambda: webbrowser.open(_CFPROXY_HELP_URL), command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
).pack(side="right") ).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain) cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
cf_domain_entry = _entry( cf_domain_entry = _entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var, ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8, height=32, radius=8,
@@ -543,14 +604,16 @@ def install_tray_config_form(
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4)) cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = _label(ctk, cf_worker_row, theme, "Cloudflare Worker домен", size=11) cf_worker_lbl = _label(ctk, cf_worker_row, theme, "Cloudflare Worker домены (через запятую)", size=11)
cf_worker_lbl.pack(anchor="w", pady=(0, 2)) cf_worker_lbl.pack(anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x") cf_worker_input.pack(fill="x")
cfproxy_worker_domain_var = ctk.StringVar( cfproxy_worker_domain_var = ctk.StringVar(
value=cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", "")) value=", ".join(coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
))
) )
cf_worker_entry = _entry( cf_worker_entry = _entry(
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var, ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
@@ -565,24 +628,24 @@ def install_tray_config_form(
btn = _cfworker_test_btn[0] btn = _cfworker_test_btn[0]
if btn is None: if btn is None:
return return
enabled = bool(cfproxy_worker_domain_var.get().strip()) enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
btn.configure(state="normal" if enabled else "disabled") btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test(): def _on_cfworker_test():
domain = cfproxy_worker_domain_var.get().strip() domains = coerce_domain_list(cfproxy_worker_domain_var.get())
btn = _cfworker_test_btn[0] btn = _cfworker_test_btn[0]
if not domain or btn is None: if not domains or btn is None:
return return
btn.configure(text="...", state="disabled") btn.configure(text="...", state="disabled")
import threading as _threading import threading as _threading
def _worker(): def _worker():
try: try:
res = _run_cfworker_connectivity_test(domain) per = _run_cfworker_multi_test(domains)
btn.after( btn.after(
0, 0,
lambda: _show_connectivity_results( lambda: _show_multi_connectivity_results(
"CF Worker", res, domain=domain, label_prefix='DC', "CF Worker", per, label_prefix='DC',
), ),
) )
except Exception as exc: except Exception as exc:
@@ -784,9 +847,9 @@ def validate_config_form(
if widgets.cfproxy_var is not None: if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get()) new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_user_domain_var is not None: if widgets.cfproxy_user_domain_var is not None:
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip() new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
if widgets.cfproxy_worker_domain_var is not None: if widgets.cfproxy_worker_domain_var is not None:
new_cfg["cfproxy_worker_domain"] = widgets.cfproxy_worker_domain_var.get().strip() new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
if widgets.appearance_var is not None: if widgets.appearance_var is not None:
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto") new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
return new_cfg return new_cfg
+3 -2
View File
@@ -18,8 +18,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"buf_kb": 256, "buf_kb": 256,
"pool_size": 4, "pool_size": 4,
"cfproxy": True, "cfproxy": True,
"cfproxy_user_domain": "", "cfproxy_user_domain": [],
"cfproxy_worker_domain": "", "cfproxy_worker_domain": [],
"ws_keepalive_interval": 30
} }
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import errno
import webbrowser
from typing import Optional, Tuple, Callable
MSG_PORT_BUSY = (
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
MSG_PERMISSION = (
"Не удалось запустить прокси:\n"
"Доступ к адресу/порту запрещён "
"(брандмауэр, антивирус или права доступа).\n\n"
"Измените порт на случайный в диапазоне 10000–50000 в настройках, "
"проверьте брандмауэр/антивирус и перезапустите."
)
MSG_BAD_ADDRESS = (
"Не удалось запустить прокси:\n"
"Некорректный или недоступный адрес для прослушивания.\n\n"
"Проверьте решение по открывшейся в браузере ссылке.\n"
"Проверьте host и порт в настройках прокси и перезапустите."
)
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
_WSA_EACCES = 10013
_WSA_EFAULT = 10014
_WSA_EADDRINUSE = 10048
_WSA_EADDRNOTAVAIL = 10049
def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[Callable]]:
"""Map a listen-socket bind failure to a user-facing message.
Returns None when the exception is not a recognizable bind failure,
so callers can fall back to generic handling.
"""
if not isinstance(exc, OSError):
return None
err = exc.errno
winerror = getattr(exc, "winerror", None)
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
return MSG_PORT_BUSY, None
if err == errno.EACCES or winerror == _WSA_EACCES:
return MSG_PERMISSION, None
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
return MSG_BAD_ADDRESS, lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
return None, None
+39
View File
@@ -0,0 +1,39 @@
"""Shared construction of the rotating log file handler.
Centralizes the rotation invariant so both the tray and the CLI log paths
behave identically and the file can never grow without bound (issue #885).
A ``RotatingFileHandler`` only rotates when ``backupCount >= 1``: CPython's
``doRollover`` skips the entire rotation block when ``backupCount == 0``, so
``maxBytes`` is silently ignored and the active file grows forever. We force
at least one backup here regardless of caller input.
"""
from __future__ import annotations
import logging.handlers
_MIN_BYTES = 32 * 1024
_MIN_BACKUPS = 1
def build_log_handler(
path: str,
log_max_mb: float = 5,
backups: int = 1,
) -> logging.handlers.RotatingFileHandler:
"""Create a RotatingFileHandler that actually rotates.
``backups`` is clamped to at least 1 so rotation is always active, and
``maxBytes`` keeps a small floor so a misconfigured tiny size can't cause
rotation on every line.
"""
max_bytes = max(_MIN_BYTES, int(log_max_mb * 1024 * 1024))
backup_count = max(_MIN_BACKUPS, int(backups))
return logging.handlers.RotatingFileHandler(
path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
+71 -19
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import logging.handlers
import os import os
import shutil
import socket as _socket import socket as _socket
import sys import sys
import threading import threading
@@ -14,16 +14,19 @@ from typing import Any, Callable, Dict, Optional, Tuple
import psutil import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.tg_ws_proxy import _run from proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from utils.diagnostics import diagnose_listen_error
from utils.logging_setup import build_log_handler
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
PORTABLE_DIR_NAME = "TgWsProxy_data"
def _app_dir() -> Path: def _standard_app_dir() -> Path:
if sys.platform == "win32": if sys.platform == "win32":
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
if sys.platform == "darwin": if sys.platform == "darwin":
@@ -31,6 +34,61 @@ def _app_dir() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
def _exe_dir() -> Optional[Path]:
try:
base = getattr(sys, "frozen", False) and sys.executable or sys.argv[0]
except Exception:
return None
if not base:
return None
p = Path(base).resolve()
return p.parent if p.is_file() else p
def _detect_portable() -> Optional[Path]:
exe_dir = _exe_dir()
if exe_dir is None:
return None
portable_dir = exe_dir / PORTABLE_DIR_NAME
if "--portable" in sys.argv:
try:
portable_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log.warning("Cannot create portable dir %s: %s", portable_dir, repr(exc))
return None
if portable_dir.is_dir():
_migrate_into_portable(portable_dir)
return portable_dir
return None
def _migrate_into_portable(portable_dir: Path) -> None:
try:
if any(portable_dir.iterdir()):
return
except OSError:
return
std = _standard_app_dir()
if not std.exists():
return
try:
for src in std.iterdir():
if ".log" in src.name:
continue
dst = portable_dir / src.name
try:
if not src.is_dir():
shutil.copy2(src, dst)
except OSError as exc:
log.warning("Portable migration: skip %s: %s", src.name, repr(exc))
except OSError as exc:
log.warning("Portable migration failed: %s", repr(exc))
def _app_dir() -> Path:
return _detect_portable() or _standard_app_dir()
APP_DIR = _app_dir() APP_DIR = _app_dir()
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = APP_DIR / "proxy.log"
@@ -155,12 +213,7 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
root.setLevel(level) root.setLevel(level)
logging.getLogger('asyncio').setLevel(logging.WARNING) logging.getLogger('asyncio').setLevel(logging.WARNING)
fh = logging.handlers.RotatingFileHandler( fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
str(LOG_FILE),
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
backupCount=0,
encoding="utf-8",
)
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh) root.addHandler(fh)
@@ -231,7 +284,7 @@ _proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
global _async_stop global _async_stop
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@@ -243,13 +296,11 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", repr(exc)) log.error("Proxy thread crashed: %s", repr(exc))
if "Address already in use" in str(exc) or "10048" in str(exc): msg, diagnose_called = diagnose_listen_error(exc)
on_port_busy( if msg:
"Не удалось запустить прокси:\n" show_error(msg)
"Порт уже используется другим приложением.\n\n" if diagnose_called:
"Закройте приложение, использующее этот порт, " diagnose_called()
"или измените порт в настройках прокси и перезапустите."
)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -271,8 +322,9 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024 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.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"]) pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
pc.cfproxy_user_domain = cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]) pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
pc.cfproxy_worker_domain = cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]) pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]))
pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
return True return True
+87 -39
View File
@@ -1,5 +1,5 @@
""" """
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей). Проверка новой версии через GitHub Releases API
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304. данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
@@ -7,7 +7,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import time import time
from itertools import zip_longest from itertools import zip_longest
@@ -19,6 +18,7 @@ from proxy.utils import build_github_opener
REPO = "Flowseal/tg-ws-proxy" REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag). # Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
@@ -37,13 +37,8 @@ _state: Dict[str, Any] = {
def _cache_file() -> Optional[Path]: def _cache_file() -> Optional[Path]:
try: try:
if sys.platform == "win32": from utils.tray_common import APP_DIR
root = Path(os.environ.get("APPDATA", str(Path.home()))) / "TgWsProxy" root = APP_DIR
elif sys.platform == "darwin":
root = Path.home() / "Library/Application Support/TgWsProxy"
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
root = (Path(xdg).expanduser() if xdg else Path.home() / ".config") / "TgWsProxy"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
return root / ".update_check_cache.json" return root / ".update_check_cache.json"
except OSError: except OSError:
@@ -229,48 +224,101 @@ def run_check(current_version: str) -> None:
_state["html_url"] = RELEASES_PAGE_URL _state["html_url"] = RELEASES_PAGE_URL
def fetch_release_by_tag(
tag: str, timeout: float = 12.0,
) -> Tuple[Optional[dict], int]:
if not tag:
return None, 0
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
}
req = Request(
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
headers=headers,
method="GET",
)
try:
with build_github_opener().open(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode()
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw), int(code)
except HTTPError as e:
if e.code in [304, 404]:
return None, e.code
raise
def _extract_assets(data: Optional[dict]) -> list:
if not data:
return []
return [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
]
def get_status() -> Dict[str, Any]: def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках).""" """Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state) return dict(_state)
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]: def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
assets = _state.get("assets") or [] new_assets = _state.get("assets") or []
if not assets: if not new_assets:
return None return None
# Try SHA256 match against release asset digests target_name = None
# SHA256 match
try: try:
import hashlib import hashlib
h = hashlib.sha256() data, code = fetch_release_by_tag(f"v{current_version}")
with open(exe_path, "rb") as f: if code == 200 and data:
while True: cur_assets = _extract_assets(data)
chunk = f.read(65536) if cur_assets:
if not chunk: h = hashlib.sha256()
break with open(exe_path, "rb") as f:
h.update(chunk) while True:
exe_sha = h.hexdigest().lower() chunk = f.read(65536)
for a in assets: if not chunk:
d = (a.get("digest") or "").lower() break
if d.startswith("sha256:") and d[7:] == exe_sha: h.update(chunk)
return a["url"], a["name"] exe_sha = h.hexdigest().lower()
for a in cur_assets:
d = (a.get("digest") or "").lower()
if d.startswith("sha256:") and d[7:] == exe_sha:
target_name = a["name"]
break
except Exception: except Exception:
pass pass
# Fallback # Fallback
import struct if not target_name or target_name not in [a.get("name") for a in new_assets]:
is_64 = struct.calcsize("P") * 8 == 64 import platform
try: import struct
is_modern = sys.getwindowsversion().major >= 10
except Exception: is_64 = struct.calcsize("P") * 8 == 64
is_modern = True machine = platform.machine().lower()
if is_modern: is_arm64 = machine in ("arm64", "aarch64")
name = "TgWsProxy_windows.exe"
elif is_64: try:
name = "TgWsProxy_windows_7_64bit.exe" is_modern = sys.getwindowsversion().major >= 10
else: except Exception:
name = "TgWsProxy_windows_7_32bit.exe" is_modern = True
for a in assets:
if a.get("name") == name: if is_arm64:
target_name = "TgWsProxy_windows_arm64.exe"
elif is_modern:
target_name = "TgWsProxy_windows.exe"
elif is_64:
target_name = "TgWsProxy_windows_7_64bit.exe"
else:
target_name = "TgWsProxy_windows_7_32bit.exe"
for a in new_assets:
if a.get("name") == target_name:
return a["url"], a["name"] return a["url"], a["name"]
return None return None
+1 -1
View File
@@ -333,7 +333,7 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?" ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form( choice = update_ctk_form(
f"Доступна новая версия: {ver}", f"Доступна новая версия: {ver}",
download_url=asset[0] if asset else None, download_url=asset[0] if asset else None,