85 Commits
v1.0.2 ... 0.3

Author SHA1 Message Date
7b92e6c8a9 0.3 2026-04-04 18:04:06 +03:00
07c019c387 0.2 2026-04-04 01:22:59 +03:00
0f4518da45 Initial commit
Some checks failed
Build tg-ws-proxy / build (aarch64, arm64, , , aarch64-3.10) (push) Has been cancelled
Build tg-ws-proxy / build (armv7, arm, 7, , armv7-3.2) (push) Has been cancelled
Build tg-ws-proxy / build (mips, mips, , softfloat, mips-3.4) (push) Has been cancelled
Build tg-ws-proxy / build (mipsel, mipsle, , softfloat, mipsel-3.4) (push) Has been cancelled
Build tg-ws-proxy / build (x64, amd64, , , x64) (push) Has been cancelled
Build tg-ws-proxy / publish-latest-release (push) Has been cancelled
2026-04-03 01:49:19 +03:00
da4b521aba Изменение README для AUR (#485) 2026-03-30 09:55:44 +03:00
07facfe18c Version bump 2026-03-29 20:00:31 +03:00
7a886dff26 Update ctk_theme.py (#480) 2026-03-29 19:56:43 +03:00
17e37f9ca0 host detect in first-run window 2026-03-29 19:55:39 +03:00
968827445f copy link, mtproto new first run notify 2026-03-29 17:57:55 +03:00
be8d178e5c secret validation 2026-03-29 17:30:39 +03:00
46426c45b0 ctk refactoring 2026-03-29 15:21:56 +03:00
c4a044542c fixes 2026-03-29 15:21:45 +03:00
af74009b11 icon fix 2026-03-28 16:48:59 +03:00
6766db9812 mtproto recode 2026-03-28 15:45:08 +03:00
95f99be26b old socks removed 2026-03-28 13:07:51 +03:00
0d11062c92 fix: остановка прокси из трея, пул WS и проверка обновлений (#443) 2026-03-27 10:54:33 +03:00
b3a9bc6a8f icon size increase 2026-03-27 09:17:15 +03:00
c179c299bb tooltip fixes 2026-03-27 09:07:25 +03:00
bd4746004e Docker image for headless proxy (#289) 2026-03-27 09:05:56 +03:00
77a0b837d9 Общий UI трея в ui/, тултипы, исправление tg:// с реальным host, доработки windows.py (импорты, lock, IPv6, остановка прокси) (#417) 2026-03-27 08:54:36 +03:00
5d28a50740 Исправление зависания "Обновления..." на iOS/iPadOS (#415) 2026-03-27 07:43:49 +03:00
7a1e2f3f5b Miss update actions/download-artifact (#412) 2026-03-23 18:06:46 +03:00
c0183bf448 Fix warn Node24 actions update & Simplify build (#410) 2026-03-23 17:10:20 +03:00
f95b9b7da0 Update win7 build according to #292 2026-03-23 09:12:06 +03:00
f3d05f7efc chore: pyproject optimization (#292) 2026-03-23 09:09:30 +03:00
e3d4578eed Добавление способа установки через AUR для Arch дистрибутивов (#296) 2026-03-23 08:39:29 +03:00
e1004e5e73 Fix macOS settings dialog cancellation flow (#392) 2026-03-23 08:39:00 +03:00
4304c71f89 build: win7 32bit support (#298) 2026-03-23 08:38:35 +03:00
3cb1929dc8 removed test script 2026-03-23 04:30:38 +03:00
afb7c5f56d revert keepalive mechanism 2026-03-22 08:00:14 +03:00
18a1bced83 logrotate #366; configurable pool and buffer sizes 2026-03-22 02:54:03 +03:00
ed85e2a284 keepalive for stale mitigation 2026-03-21 09:26:34 +03:00
c1452c23da Optimizations 2026-03-20 22:57:15 +03:00
6a80ca85e3 Optimizations 2026-03-19 22:07:47 +03:00
4ae7cb92f7 autostart fixes 2026-03-19 12:26:31 +03:00
7eeb447a76 add windows autostart (#171) 2026-03-19 11:27:59 +03:00
5d839c1112 fix for default dc options 2026-03-19 11:09:07 +03:00
0dc2a9cac6 built files rename 2026-03-19 07:43:42 +03:00
7943c539b6 .deb build test 2026-03-19 07:28:46 +03:00
5e53a8a470 unused import 2026-03-19 07:03:11 +03:00
692157b0f5 Linux binary, github actions (#282) 2026-03-19 06:55:55 +03:00
26542558c6 dc fail logic rewrite for independent usability 2026-03-19 06:23:58 +03:00
e6ee4e6159 Hardcoded dc override for 203 2026-03-19 05:53:14 +03:00
96383057c6 dc203 for possible overriding 2026-03-19 05:42:40 +03:00
646468680c Speed improvements 2026-03-19 02:36:17 +03:00
51aca9009f removed req files 2026-03-18 22:03:57 +03:00
6b9ddda7f0 readme simplify 2026-03-18 21:58:35 +03:00
54c6f3881b pyproject fixes; macos support 2026-03-18 21:54:58 +03:00
99b5c722e1 build: migrate deps to pyproject.toml (#201) 2026-03-18 21:33:12 +03:00
9924440c48 Update macos.py (#272) 2026-03-18 20:27:16 +03:00
7572258a28 MacOS build simplify, readme update 2026-03-18 19:22:46 +03:00
d2190cfec6 cffi universal2 fix 2026-03-18 18:15:06 +03:00
053ec3e00f Universal2 macos test 2026-03-18 18:11:07 +03:00
55affaf78f macos dialog fix; macos merge logs 2026-03-18 17:49:24 +03:00
533420b516 MacOS support (#225) 2026-03-18 17:33:38 +03:00
473078593a Merge pull request #244 from hir-lol/main 2026-03-18 01:40:09 +03:00
46011c0ff5 Github optional release on build 2026-03-17 22:18:21 +03:00
8219b9f144 pyinstaller changed to previous version for false detect prevention 2026-03-17 22:15:04 +03:00
cf3e3b2aec typos 2026-03-16 04:09:39 +03:00
3fdce27fbb Media chunking fix; Removed high number dc detection 2026-03-16 04:04:54 +03:00
1433c2e881 typo in readme 2026-03-15 15:55:23 +03:00
f774777539 Merge pull request #141 from nullptr-deref/main 2026-03-15 15:50:57 +03:00
b6cb5aa76f general typos fix 2026-03-15 15:29:19 +03:00
7574357db9 update readme 2026-03-15 14:17:07 +03:00
2571847a9e issue template 2026-03-15 14:10:04 +03:00
f5d7797259 build fix 2026-03-15 05:19:15 +03:00
d5a3eb5157 build fix 2026-03-15 05:06:16 +03:00
e4891cfd53 hardcode host connection 2026-03-15 05:00:50 +03:00
a0a5bfbecb IPv6 warnings 2026-03-15 04:56:26 +03:00
1c227b924a Optimization, connections pool 2026-03-15 04:34:05 +03:00
72e5040e6d fix #83 2026-03-15 02:33:20 +03:00
0297bf8305 Unstripped build 2026-03-15 01:44:37 +03:00
8bcbcd2787 media dc fix on mobiles 2026-03-13 13:34:22 +03:00
f744e93de6 Mobiles media fix, optimizations 2026-03-12 19:36:02 +03:00
6147cda356 unknown behavior on mobiles with media dcs 2026-03-10 14:21:31 +03:00
3cf12467a7 Host configuration 2026-03-07 21:52:59 +03:00
48282a63d4 code cleaning 2026-03-07 21:14:17 +03:00
39dd71be14 Lock recode, bind error notify, clipboard cross-platform 2026-03-07 21:10:35 +03:00
46aec5e3b6 Win7 bundle 2026-03-07 18:08:42 +03:00
7e3732b04b reqs version freeze 2026-03-07 17:18:05 +03:00
5586d194db workflow windows path fix 2026-03-06 19:59:32 +03:00
f69d20ad85 Restructure 2026-03-06 19:48:12 +03:00
01b3aca85e code simplify 2026-03-06 19:08:46 +03:00
9e9448dda0 imports clear 2026-03-06 17:13:00 +03:00
f8a10d9940 Mapping unknown DC by IP for mobile clients 2026-03-06 02:47:59 +03:00
e57f61a621 unused const 2026-03-05 20:42:29 +03:00
74 changed files with 2249 additions and 1709 deletions

4
.config.example Normal file
View File

@ -0,0 +1,4 @@
PLATFORM=entware
TARGET=aarch64-3.10
GOOS=linux
GOARCH=arm64

View File

@ -1,50 +1,165 @@
name: Build & Release
name: Build tg-ws-proxy
on:
workflow_dispatch:
inputs:
version:
description: "Release version tag (e.g. v1.0.0)"
required: true
default: "v1.0.0"
release:
types: [created]
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
prepare:
name: Prepare build matrix
runs-on: ubuntu-latest
outputs:
configs: ${{ steps.collect.outputs.configs }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v5
- name: Build config matrix
id: collect
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
from pathlib import Path
configs = sorted(Path("config").glob("*/*.config"))
if not configs:
raise SystemExit("No build configs found")
matrix = []
for config in configs:
matrix.append({
"config": config.as_posix(),
"name": config.stem,
})
print(f"configs={json.dumps(matrix, separators=(',', ':'))}")
PY
build:
name: Build packages (${{ matrix.name }})
needs: prepare
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.prepare.outputs.configs) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
python-version: "3.12"
cache: "pip"
go-version-file: src/go.mod
cache: true
cache-dependency-path: src/go.sum
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build and package
run: |
cp "${{ matrix.config }}" .config
make package
- name: Build EXE with PyInstaller
run: pyinstaller tg_ws_proxy.spec --noconfirm
- name: Collect package files
id: package_outputs
run: |
mapfile -t packages < <(find .build -maxdepth 1 -type f -name 'tg-ws-proxy_*.ipk' | sort)
if [ ${#packages[@]} -eq 0 ]; then
echo "No package files found" >&2
exit 1
fi
if [ ${#packages[@]} -gt 1 ]; then
echo "Expected one package file, found ${#packages[@]}" >&2
printf '%s\n' "${packages[@]}"
exit 1
fi
echo "package_path=${packages[0]}" >> "$GITHUB_OUTPUT"
echo "package_name=$(basename "${packages[0]}")" >> "$GITHUB_OUTPUT"
- name: Upload artifact
uses: actions/upload-artifact@v4
- name: Upload package artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy
path: dist/TgWsProxy.exe
name: ${{ steps.package_outputs.outputs.package_name }}
path: ${{ steps.package_outputs.outputs.package_path }}
if-no-files-found: error
compression-level: 0
- name: Create GitHub Release
- name: Upload package asset to release
if: ${{ github.event_name == 'release' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.version }}
name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: |
## TG WS Proxy ${{ github.event.inputs.version }}
files: dist/TgWsProxy.exe
draft: false
prerelease: false
files: ${{ steps.package_outputs.outputs.package_path }}
publish-latest-release:
runs-on: ubuntu-latest
needs: [ build ]
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: out
merge-multiple: true
- name: Resolve latest target release
id: target-release
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const picked = releases.find((rel) => !rel.draft && !rel.prerelease);
if (!picked) {
core.setFailed('No release found to update');
return;
}
core.setOutput('id', String(picked.id));
core.setOutput('tag', picked.tag_name);
- name: Remove previous ipk assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.target-release.outputs.id }}
run: |
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
--jq '.[] | select(.name | test("^tg-ws-proxy.*\\.ipk$")) | .id' \
| while read -r asset_id; do
[ -n "$asset_id" ] || continue
gh api -X DELETE "repos/${{ github.repository }}/releases/assets/${asset_id}"
done
- name: Upload new ipk assets to latest release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.target-release.outputs.tag }}
run: |
mapfile -t files < <(find out -type f -name 'tg-ws-proxy_*.ipk' | sort)
if [ ${#files[@]} -eq 0 ]; then
echo "No ipk files found"
exit 1
fi
printf '%s\n' "${files[@]}"
gh release upload "$TAG" "${files[@]}" --clobber
- name: Dispatch to feedly
if: success()
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.AGGREGATOR_PAT }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/spatiumstas/feedly/dispatches \
-d '{"event_type":"package-built","client_payload":{"package":"'"${{ github.repository }}"'","channel":"release","tag":"'"${{ steps.target-release.outputs.tag }}"'"}}'

29
.gitignore vendored
View File

@ -1,29 +0,0 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
dist/
build/
*.spec.bak
# PyInstaller
*.manifest
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
Thumbs.db
Desktop.ini
.DS_Store
# Project-specific (not for the repo)
scan_ips.py
scan.txt
AyuGramDesktop-dev/
tweb-master/

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Flowseal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

101
Makefile Normal file
View File

@ -0,0 +1,101 @@
-include .config
SHELL := /bin/bash
PKG_NAME := tg-ws-proxy
PKG_DESCRIPTION := Telegram MTProto WS bridge proxy (Go binary)
PKG_LICENSE := MIT
PKG_SECTION := net
PKG_MAINTAINER := tg-ws-proxy maintainers
PKG_VERSION := $(shell cat VERSION)
PKG_REVISION ?= 1
PLATFORM ?=
TARGET ?=
GOOS ?=
GOARCH ?=
GOARM ?=
GOMIPS ?=
CGO_ENABLED ?= 0
GO_PROXY_DIR ?= src
ifeq ($(PLATFORM),entware)
PKG_DEPENDS := ca-certificates
else ifeq ($(PLATFORM),openwrt)
PKG_DEPENDS := ca-certificates
else
$(error Unsupported PLATFORM='$(PLATFORM)'; expected entware or openwrt)
endif
BUILDS_DIR := ./.build
BUILD_DIR := $(BUILDS_DIR)/$(PLATFORM)_$(TARGET)
COMPILE_DIR := $(BUILD_DIR)/compile
ROOT_DIR := $(BUILD_DIR)/root
CONTROL_DIR := $(BUILD_DIR)/control
ifeq ($(PLATFORM),entware)
BIN_DIR := $(ROOT_DIR)/opt/bin
ETC_DIR := $(ROOT_DIR)/opt/etc
VAR_DIR := $(ROOT_DIR)/opt/var
else
BIN_DIR := $(ROOT_DIR)/usr/bin
ETC_DIR := $(ROOT_DIR)/etc
VAR_DIR := $(ROOT_DIR)/var
endif
define _copy_files
if [ -d $(1)/_ipk/control ]; then mkdir -p "$(CONTROL_DIR)"; cp -r $(1)/_ipk/control/* "$(CONTROL_DIR)"; fi
if [ -d $(1)/bin ]; then mkdir -p "$(BIN_DIR)"; cp -r $(1)/bin/* "$(BIN_DIR)"; fi
if [ -d $(1)/etc ]; then mkdir -p "$(ETC_DIR)"; cp -r $(1)/etc/* "$(ETC_DIR)"; fi
if [ -d $(1)/var ]; then mkdir -p "$(VAR_DIR)"; cp -r $(1)/var/* "$(VAR_DIR)"; fi
endef
PACKAGE_FILE := $(BUILDS_DIR)/$(PKG_NAME)_$(PKG_VERSION)-$(PKG_REVISION)_$(PLATFORM)_$(TARGET).ipk
.PHONY: all clean build prepare_files package package_ipk
all: build package
clean:
rm -rf $(BUILDS_DIR)
build:
mkdir -p "$(COMPILE_DIR)"
cd "$(GO_PROXY_DIR)" && \
GOOS="$(GOOS)" GOARCH="$(GOARCH)" GOARM="$(GOARM)" GOMIPS="$(GOMIPS)" CGO_ENABLED="$(CGO_ENABLED)" \
go build -trimpath -o "$(abspath $(COMPILE_DIR))/tg-ws-proxy" .
prepare_files: build
rm -rf "$(ROOT_DIR)" "$(CONTROL_DIR)"
mkdir -p "$(BIN_DIR)" "$(CONTROL_DIR)"
cp "$(COMPILE_DIR)/tg-ws-proxy" "$(BIN_DIR)/tg-ws-proxy"
$(call _copy_files,./files/common)
$(if $(filter entware,$(PLATFORM)), $(call _copy_files,./files/entware))
$(if $(filter openwrt,$(PLATFORM)), $(call _copy_files,./files/openwrt))
echo "Package: $(PKG_NAME)" > "$(CONTROL_DIR)/control"
echo "Version: $(PKG_VERSION)-$(PKG_REVISION)" >> "$(CONTROL_DIR)/control"
echo "Depends: $(PKG_DEPENDS)" >> "$(CONTROL_DIR)/control"
echo "Section: $(PKG_SECTION)" >> "$(CONTROL_DIR)/control"
echo "Architecture: $(TARGET)" >> "$(CONTROL_DIR)/control"
echo "License: $(PKG_LICENSE)" >> "$(CONTROL_DIR)/control"
echo "Maintainer: $(PKG_MAINTAINER)" >> "$(CONTROL_DIR)/control"
echo "Description: $(PKG_DESCRIPTION)" >> "$(CONTROL_DIR)/control"
chmod +x "$(BIN_DIR)/tg-ws-proxy"
if [ -d "$(ETC_DIR)/init.d" ]; then chmod +x "$(ETC_DIR)/init.d"/*; fi
if [ -f "$(CONTROL_DIR)/prerm" ]; then chmod +x "$(CONTROL_DIR)/prerm"; fi
if [ -f "$(CONTROL_DIR)/postinst" ]; then chmod +x "$(CONTROL_DIR)/postinst"; fi
if [ -f "$(CONTROL_DIR)/postrm" ]; then chmod +x "$(CONTROL_DIR)/postrm"; fi
package: package_ipk
package_ipk: prepare_files
mkdir -p "$(BUILDS_DIR)"
echo 2.0 > "$(BUILD_DIR)/debian-binary"
tar -C "$(CONTROL_DIR)" -czf "$(BUILD_DIR)/control.tar.gz" --owner=0 --group=0 .
tar -C "$(ROOT_DIR)" -czf "$(BUILD_DIR)/data.tar.gz" --owner=0 --group=0 .
tar -C "$(BUILD_DIR)" -czf "$(PACKAGE_FILE)" --owner=0 --group=0 debian-binary control.tar.gz data.tar.gz
@echo "Built: $(PACKAGE_FILE)"

164
README.md
View File

@ -1,119 +1,105 @@
# TG WS Proxy
# TG WS Proxy Go for embedded devices
Локальный SOCKS5-прокси для Telegram Desktop, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram.
**Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
## Как это работает
### Install (KeeneticOS)
Repository:
```shell
curl -fsSL https://raw.githubusercontent.com/spatiumstas/feedly/main/add-repo.sh | sh
```
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.telegram.org) → Telegram DC
Package:
```shell
opkg install tg-ws-proxy
```
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org`
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
## Установка
### Из исходников
```bash
pip install -r requirements.txt
### Install (manual)
Insert package link from Releases
```shell
opkg install %link%
```
## Использование
### Config
### Tray-приложение (рекомендуется для Windows)
Main config file:
```bash
python tg_ws_tray.py
```shell
# Entware (KeeneticOS): /opt/etc/tg-ws-proxy.conf
# OpenWrt/generic opkg: /etc/tg-ws-proxy.conf
```
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
Minimal config example:
**Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
### Консольный режим
```bash
python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
```conf
HOST=0.0.0.0
PORT=1443
SECRET=
LOG_LEVEL=0
DC_IP_DEFAULT=149.154.167.220
DC_IP_DEFAULT_POOL=""
EXTRA_ARGS=""
```
**Аргументы:**
> Notes:
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
1. `SECRET` must be 32 hex chars. If empty, it is auto-generated during install.
2. `DC_IP_DEFAULT` and `DC_IP_DEFAULT_POOL` are global defaults for implicit DC map (`2,4`).
3. `EXTRA_ARGS` is for per-DC overrides and extra runtime flags.
**Примеры:**
Override examples:
```bash
# Стандартный запуск
python tg_ws_proxy.py
```conf
# Per-DC pool override (DC2)
EXTRA_ARGS="--dc-ip-pool 2:149.154.175.50,149.154.167.220"
# Другой порт и дополнительные DC
python tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
python tg_ws_proxy.py -v
# Per-DC single IP override (DC203) + verbose logs
EXTRA_ARGS="--dc-ip 203:91.105.192.100 -v"
```
## Настройка Telegram Desktop
### Run
### Автоматически
```shell
# Entware (KeeneticOS)
/opt/etc/init.d/S61tg-ws-proxy start
/opt/etc/init.d/S61tg-ws-proxy status
/opt/etc/init.d/S61tg-ws-proxy restart
/opt/etc/init.d/S61tg-ws-proxy stop
ПКМ по иконке в трее → **«Открыть в Telegram»**
### Вручную
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
## Конфигурация
Tray-приложение хранит конфигурацию в `%APPDATA%/TgWsProxy/config.json`:
```json
{
"port": 1080,
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false
}
# OpenWrt/generic opkg
/etc/init.d/tg-ws-proxy start
/etc/init.d/tg-ws-proxy status
/etc/init.d/tg-ws-proxy restart
/etc/init.d/tg-ws-proxy stop
```
Логи записываются в `%APPDATA%/TgWsProxy/proxy.log`.
### Logs
## Сборка exe
If `LOG_LEVEL=1`, service logs are written to:
Проект содержит спецификацию PyInstaller ([`tg_ws_proxy.spec`](tg_ws_proxy.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
```bash
pip install pyinstaller
pyinstaller tg_ws_proxy.spec
```shell
# Entware (KeeneticOS): /opt/var/log/tg-ws-proxy.log
# OpenWrt/generic opkg: /var/log/tg-ws-proxy.log
```
## Дисклеймер
Проект частично vibecoded by Opus 4.6. Если вы найдете баг, то создайте Issue с его описанем.
### Build from profile
## Лицензия
```shell
cp config/entware/aarch64-3.10.config .config
make package
```
[MIT License](LICENSE)
Output package:
```shell
.build/tg-ws-proxy_<version>-1_<platform>_<target>.ipk
```
### Remove
```shell
opkg remove tg-ws-proxy
```
### Remove repository
```shell
rm /opt/etc/opkg/feedly.conf
```

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.3

View File

@ -0,0 +1,4 @@
PLATFORM=entware
TARGET=aarch64-3.10
GOOS=linux
GOARCH=arm64

View File

@ -0,0 +1,5 @@
PLATFORM=entware
TARGET=armv7-3.2
GOOS=linux
GOARCH=arm
GOARM=7,softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=entware
TARGET=mips-3.4
GOOS=linux
GOARCH=mips
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=entware
TARGET=mipsel-3.4
GOOS=linux
GOARCH=mipsle
GOMIPS=softfloat

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=aarch64_cortex-a53
GOOS=linux
GOARCH=arm64

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=aarch64_cortex-a72
GOOS=linux
GOARCH=arm64

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=aarch64_cortex-a76
GOOS=linux
GOARCH=arm64

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=aarch64_generic
GOOS=linux
GOARCH=arm64

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_arm1176jzf-s_vfp
GOOS=linux
GOARCH=arm
GOARM=6

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_arm926ej-s
GOOS=linux
GOARCH=arm
GOARM=5

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a15_neon-vfpv4
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a5_vfpv4
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a7
GOOS=linux
GOARCH=arm
GOARM=5

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a7_neon-vfpv4
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a7_vfpv4
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a8_vfpv3
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a9
GOOS=linux
GOARCH=arm
GOARM=5

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a9_neon
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_cortex-a9_vfpv3-d16
GOOS=linux
GOARCH=arm
GOARM=7

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_fa526
GOOS=linux
GOARCH=arm
GOARM=5

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=arm_xscale
GOOS=linux
GOARCH=arm
GOARM=5

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=i386_pentium-mmx
GOOS=linux
GOARCH=386
GO386=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=i386_pentium4
GOOS=linux
GOARCH=386
GO386=sse2

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=loongarch64_generic
GOOS=linux
GOARCH=loong64

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mips64_mips64r2
GOOS=linux
GOARCH=mips64
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mips64_octeonplus
GOOS=linux
GOARCH=mips64
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mips64el_mips64r2
GOOS=linux
GOARCH=mips64le
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mips_24kc
GOOS=linux
GOARCH=mips
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mips_4kec
GOOS=linux
GOARCH=mips
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mips_mips32
GOOS=linux
GOARCH=mips
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mipsel_24kc
GOOS=linux
GOARCH=mipsle
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mipsel_24kc_24kf
GOOS=linux
GOARCH=mipsle
GOMIPS=hardfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mipsel_74kc
GOOS=linux
GOARCH=mipsle
GOMIPS=softfloat

View File

@ -0,0 +1,5 @@
PLATFORM=openwrt
TARGET=mipsel_mips32
GOOS=linux
GOARCH=mipsle
GOMIPS=softfloat

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=riscv64_generic
GOOS=linux
GOARCH=riscv64

View File

@ -0,0 +1,4 @@
PLATFORM=openwrt
TARGET=x86_64
GOOS=linux
GOARCH=amd64

View File

@ -0,0 +1,16 @@
HOST=0.0.0.0
PORT=1443
# 32 hex chars
SECRET=
# Proxy verbosity: 0 = no service log file, 1 = write /opt/var/log/tg-ws-proxy.log
LOG_LEVEL=0
# Default WS target IP used for implicit DC map (2,4) when no --dc-ip is provided
DC_IP_DEFAULT=149.154.167.220
# Optional default target IP pool for implicit DC map, comma-separated
# Example: DC_IP_DEFAULT_POOL="149.154.167.220,149.154.175.50"
# Applies to implicit DCs (2,4) unless overridden via --dc-ip/--dc-ip-pool in EXTRA_ARGS.
DC_IP_DEFAULT_POOL=""
# Optional per-DC overrides and extra runtime flags.
# Keep defaults in DC_IP_DEFAULT / DC_IP_DEFAULT_POOL, and use EXTRA_ARGS only for exceptions.
# Example: EXTRA_ARGS="--dc-ip-pool 2:149.154.175.50,149.154.167.220 --dc-ip 203:91.105.192.100 -v"
EXTRA_ARGS=""

View File

@ -0,0 +1 @@
/opt/etc/tg-ws-proxy.conf

View File

@ -0,0 +1,27 @@
#!/bin/sh
set -e
chmod +x /opt/bin/tg-ws-proxy || true
chmod +x /opt/etc/init.d/S61tg-ws-proxy || true
CONFFILE=/opt/etc/tg-ws-proxy.conf
INIT_SCRIPT=/opt/etc/init.d/S61tg-ws-proxy
if [ -f "$CONFFILE" ]; then
. "$CONFFILE" || true
if [ -z "${SECRET:-}" ]; then
secret="$(/opt/bin/tg-ws-proxy --gen-secret 2>/dev/null | tr -d ' \r\n' || true)"
if [ "${#secret}" -eq 32 ]; then
if grep -Eq '^[[:space:]]*SECRET=' "$CONFFILE"; then
sed -i "s|^[[:space:]]*SECRET=.*$|SECRET=$secret|" "$CONFFILE"
else
printf '\nSECRET=%s\n' "$secret" >> "$CONFFILE"
fi
echo "Generated SECRET in $CONFFILE"
else
echo "WARNING: failed to generate SECRET automatically" >&2
fi
fi
fi
"$INIT_SCRIPT" restart || true

View File

@ -0,0 +1,10 @@
#!/bin/sh
set -e
[ "${PKG_UPGRADE}" = "1" ] && exit 0
rm -f /opt/bin/tg-ws-proxy
rm -f /opt/etc/init.d/S61tg-ws-proxy
rm -f /opt/etc/tg-ws-proxy.conf-opkg
echo "tg-ws-proxy removed"

View File

@ -0,0 +1,5 @@
#!/bin/sh
/opt/etc/init.d/S61tg-ws-proxy stop
exit 0

View File

@ -0,0 +1,123 @@
#!/bin/sh
ENABLED=yes
PROCS=tg-ws-proxy
DESC="TG WS Proxy"
PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ACTION=$1
CALLER=$2
PROG=/opt/bin/tg-ws-proxy
CONFFILE=/opt/etc/tg-ws-proxy.conf
LOGFILE=/opt/var/log/tg-ws-proxy.log
ansi_red="\033[1;31m";
ansi_white="\033[1;37m";
ansi_green="\033[1;32m";
ansi_yellow="\033[1;33m";
ansi_blue="\033[1;34m";
ansi_std="\033[m";
load_config() {
if [ ! -f "$CONFFILE" ]; then
echo "Config file not found: $CONFFILE" >&2
return 1
fi
. "$CONFFILE"
return 0
}
print_link() {
if [ -z "$HOST" ] || [ -z "$PORT" ] || [ -z "$SECRET" ]; then
echo "Missing HOST/PORT/SECRET in $CONFFILE" >&2
return 1
fi
link_host="$HOST"
br_ip="$(ip -f inet addr show dev br0 2>/dev/null | sed -n 's/.*inet \([0-9.]\+\)\/.*/\1/p' | head -n 1)"
[ -n "$br_ip" ] && link_host="$br_ip"
echo -e "$ansi_blue Connect link: tg://proxy?server=$link_host&port=$PORT&secret=dd$SECRET $ansi_std"
logger "Connect link: tg://proxy?server=$link_host&port=$PORT&secret=dd$SECRET"
}
start() {
load_config || return 1
if [ -z "$SECRET" ]; then
echo "SECRET is empty in $CONFFILE" >&2
return 1
fi
echo -e -n "$ansi_white Starting $DESC... $ansi_std"
if [ -n "`pidof $PROC`" ]; then
echo -e " $ansi_yellow already running. $ansi_std"
return 0
fi
if [ "$LOG_LEVEL" = "1" ]; then
"$PROG" --host "$HOST" --port "$PORT" --secret "$SECRET" --dc-ip-default "$DC_IP_DEFAULT" --dc-ip-default-pool "$DC_IP_DEFAULT_POOL" $EXTRA_ARGS >>"$LOGFILE" 2>&1 &
else
"$PROG" --host "$HOST" --port "$PORT" --secret "$SECRET" --dc-ip-default "$DC_IP_DEFAULT" --dc-ip-default-pool "$DC_IP_DEFAULT_POOL" $EXTRA_ARGS >/dev/null 2>&1 &
fi
if [ -z "`pidof $PROC`" ]; then
echo -e " $ansi_red failed. $ansi_std"
logger "Failed to start $DESC from $CALLER."
return 255
else
echo -e " $ansi_green done. $ansi_std"
logger "Started $DESC from $CALLER."
print_link
return 0
fi
}
stop() {
echo -e -n "$ansi_white Shutting down $PROC... $ansi_std"
killall $PROC 2>/dev/null
if [ -n "`pidof $PROC`" ]; then
echo -e " $ansi_red failed. $ansi_std"
return 255
else
echo -e " $ansi_green done. $ansi_std"
return 0
fi
}
status() {
echo -e -n "$ansi_white Checking $DESC... $ansi_std"
if [ -n "`pidof $PROC`" ]; then
echo -e " $ansi_green alive. $ansi_std";
load_config || return 1
print_link
return 0
else
echo -e " $ansi_red dead. $ansi_std";
return 1
fi
}
for PROC in $PROCS; do
case $ACTION in
start)
start
;;
stop)
stop
;;
restart)
stop && start
;;
status)
status
;;
*)
echo -e "$ansi_white Usage: $0 (start|stop|restart|status)$ansi_std"
exit 1
;;
esac
done

View File

@ -0,0 +1 @@
/etc/tg-ws-proxy.conf

View File

@ -0,0 +1,27 @@
#!/bin/sh
set -e
chmod +x /usr/bin/tg-ws-proxy || true
chmod +x /etc/init.d/tg-ws-proxy || true
CONFFILE=/etc/tg-ws-proxy.conf
INIT_SCRIPT=/etc/init.d/tg-ws-proxy
if [ -f "$CONFFILE" ]; then
. "$CONFFILE" || true
if [ -z "${SECRET:-}" ]; then
secret="$(/usr/bin/tg-ws-proxy --gen-secret 2>/dev/null | tr -d ' \r\n' || true)"
if [ "${#secret}" -eq 32 ]; then
if grep -Eq '^[[:space:]]*SECRET=' "$CONFFILE"; then
sed -i "s|^[[:space:]]*SECRET=.*$|SECRET=$secret|" "$CONFFILE"
else
printf '\nSECRET=%s\n' "$secret" >> "$CONFFILE"
fi
echo "Generated SECRET in $CONFFILE"
else
echo "WARNING: failed to generate SECRET automatically" >&2
fi
fi
fi
"$INIT_SCRIPT" restart || true

View File

@ -0,0 +1,10 @@
#!/bin/sh
set -e
[ "${PKG_UPGRADE}" = "1" ] && exit 0
rm -f /usr/bin/tg-ws-proxy
rm -f /etc/init.d/tg-ws-proxy
rm -f /etc/tg-ws-proxy.conf-opkg
echo "tg-ws-proxy removed"

View File

@ -0,0 +1,5 @@
#!/bin/sh
service tg-ws-proxy stop
exit 0

View File

@ -0,0 +1,3 @@
config tg-ws-proxy 'main'
option enabled '1'
option user 'root'

View File

@ -0,0 +1,57 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=66
script=$(readlink "$initscript")
NAME="$(basename ${script:-$initscript})"
PROG="/usr/bin/tg-ws-proxy"
print_link() {
local conffile link_host br_ip
conffile="/etc/tg-ws-proxy.conf"
[ -f "$conffile" ] || return 1
. "$conffile"
[ -n "$HOST" ] && [ -n "$PORT" ] && [ -n "$SECRET" ] || return 1
link_host="$HOST"
if [ "$link_host" = "0.0.0.0" ]; then
br_ip="$(ip -f inet addr show dev br-lan 2>/dev/null | sed -n 's/.*inet \([0-9.]\+\)\/.*/\1/p' | head -n 1)"
[ -n "$br_ip" ] || br_ip="$(ip -f inet addr show dev br0 2>/dev/null | sed -n 's/.*inet \([0-9.]\+\)\/.*/\1/p' | head -n 1)"
[ -n "$br_ip" ] && link_host="$br_ip"
fi
echo "Connect link: tg://proxy?server=$link_host&port=$PORT&secret=dd$SECRET"
}
start_service() {
config_load "$NAME"
local enabled user
config_get_bool enabled "main" "enabled" "0"
[ "$enabled" -eq "1" ] || return 0
config_get user "main" "user" "root"
procd_open_instance "$NAME.main"
procd_set_param command "$PROG"
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
status_service() {
if [ -n "$(pidof tg-ws-proxy 2>/dev/null)" ]; then
echo "tg-ws-proxy is running"
print_link || true
return 0
fi
echo "tg-ws-proxy is not running"
return 1
}

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -1,6 +0,0 @@
cryptography
pystray
Pillow
customtkinter
pyinstaller
psutil

168
src/config.go Normal file
View File

@ -0,0 +1,168 @@
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"log"
"net"
"strconv"
"strings"
)
func parseFlags() (*Config, error) {
host := flag.String("host", "127.0.0.1", "Listen host")
port := flag.Int("port", 1443, "Listen port")
secret := flag.String("secret", "", "MTProto secret (32 hex chars)")
genSecret := flag.Bool("gen-secret", false, "Generate random secret and print it")
verbose := flag.Bool("v", false, "Verbose logs")
logFile := flag.String("log-file", "", "Log file path")
logMaxMB := flag.Float64("log-max-mb", 5, "Max log file size before rotate")
logBackups := flag.Int("log-backups", 0, "Number of rotated backups")
bufKB := flag.Int("buf-kb", 256, "Socket buffer size in KB")
poolSize := flag.Int("pool-size", 4, "WS pool size per DC")
maxConns := flag.Int("max-conns", defaultMaxConns, "Max concurrent client sessions")
dcIPDefault := flag.String("dc-ip-default", "149.154.167.220", "Default WS target IP for all implicit DCs when --dc-ip is not provided")
dcIPDefaultPool := flag.String("dc-ip-default-pool", "", "Default WS target IP pool for implicit DCs, comma-separated")
pprofListen := flag.String("pprof-listen", "", "Optional pprof listen address (e.g. 127.0.0.1:6060)")
var dcIPs multiFlag
var dcIPPools multiFlag
flag.Var(&dcIPs, "dc-ip", "Target DC IP as DC:IP; repeatable")
flag.Var(&dcIPPools, "dc-ip-pool", "Target pool as DC:IP1,IP2,...; repeatable")
flag.Parse()
if *secret == "" {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
*secret = hex.EncodeToString(b)
if !*genSecret {
log.Printf("INFO Generated secret: %s", *secret)
}
}
if len(*secret) != 32 {
return nil, errors.New("secret must be exactly 32 hex chars")
}
if _, err := hex.DecodeString(*secret); err != nil {
return nil, errors.New("secret must be valid hex")
}
defaultTargetIP := strings.TrimSpace(*dcIPDefault)
if net.ParseIP(defaultTargetIP) == nil {
return nil, fmt.Errorf("invalid --dc-ip-default: %s", defaultTargetIP)
}
defaultPool := []string{defaultTargetIP}
if strings.TrimSpace(*dcIPDefaultPool) != "" {
poolIPs, err := parseIPCSV(*dcIPDefaultPool)
if err != nil {
return nil, fmt.Errorf("invalid --dc-ip-default-pool: %w", err)
}
defaultPool = poolIPs
}
dcMap := map[int]string{}
dcPool := map[int][]string{}
for _, dc := range []int{2, 4} {
dcPool[dc] = append([]string(nil), defaultPool...)
dcMap[dc] = defaultPool[0]
}
for _, item := range dcIPs {
parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid --dc-ip: %s", item)
}
dc, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid dc: %s", parts[0])
}
if net.ParseIP(parts[1]) == nil {
return nil, fmt.Errorf("invalid ip: %s", parts[1])
}
ip := parts[1]
dcPool[dc] = []string{ip}
dcMap[dc] = ip
}
for _, item := range dcIPPools {
parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid --dc-ip-pool: %s", item)
}
dc, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid dc in --dc-ip-pool: %s", parts[0])
}
poolIPs, err := parseIPCSV(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid --dc-ip-pool for dc %d: %w", dc, err)
}
dcPool[dc] = append([]string(nil), poolIPs...)
dcMap[dc] = dcPool[dc][0]
}
return &Config{
Host: *host,
Port: *port,
SecretHex: *secret,
GenSecret: *genSecret,
DCMap: dcMap,
DCPool: dcPool,
Verbose: *verbose,
BufKB: maxInt(*bufKB, 4),
PoolSize: maxInt(*poolSize, 0),
MaxConns: maxInt(*maxConns, 1),
LogFile: *logFile,
LogMaxMB: *logMaxMB,
LogBackups: maxInt(*logBackups, 0),
PprofListen: strings.TrimSpace(*pprofListen),
}, nil
}
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error {
*m = append(*m, v)
return nil
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func parseIPCSV(raw string) ([]string, error) {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
ip := strings.TrimSpace(p)
if ip == "" {
continue
}
if net.ParseIP(ip) == nil {
return nil, fmt.Errorf("invalid ip: %s", ip)
}
out = appendUniqueIP(out, ip)
}
if len(out) == 0 {
return nil, errors.New("empty ip pool")
}
return out, nil
}
func appendUniqueIP(dst []string, ip string) []string {
for _, v := range dst {
if v == ip {
return dst
}
}
return append(dst, ip)
}

54
src/constants.go Normal file
View File

@ -0,0 +1,54 @@
package main
import "time"
const (
handshakeLen = 64
skipLen = 8
prekeyLen = 32
keyLen = 32
ivLen = 16
protoTagPos = 56
dcIdxPos = 60
protoAbridgedInt = 0xEFEFEFEF
protoIntermediateInt = 0xEEEEEEEE
protoPaddedIntermediateInt = 0xDDDDDDDD
wsPoolMaxAge = 120 * time.Second
dcFailCooldown = 30 * time.Second
ioIdleTimeout = 90 * time.Second
wsWriteTimeout = 15 * time.Second
statsFlushBytes = 256 * 1024
acceptPollTimeout = 1 * time.Second
acceptBackoffMin = 5 * time.Millisecond
acceptBackoffMax = 1 * time.Second
defaultMaxConns = 1024
)
var (
protoTagAbridged = []byte{0xef, 0xef, 0xef, 0xef}
protoTagIntermediate = []byte{0xee, 0xee, 0xee, 0xee}
protoTagSecure = []byte{0xdd, 0xdd, 0xdd, 0xdd}
reservedFirst = map[byte]bool{0xef: true}
reservedStart = [][]byte{
[]byte("HEAD"),
[]byte("POST"),
[]byte("GET "),
{0xee, 0xee, 0xee, 0xee},
{0xdd, 0xdd, 0xdd, 0xdd},
{0x16, 0x03, 0x01, 0x02},
}
dcFallbackDefaults = map[int]string{
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",
}
dcOverrides = map[int]int{203: 2}
)

188
src/crypto.go Normal file
View File

@ -0,0 +1,188 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"math"
"strconv"
)
func tryHandshake(handshake, secret []byte) (*handshakeInfo, bool) {
if len(handshake) != handshakeLen {
return nil, false
}
decPrekeyAndIV := handshake[skipLen : skipLen+prekeyLen+ivLen]
decPrekey := decPrekeyAndIV[:prekeyLen]
decIV := decPrekeyAndIV[prekeyLen:]
h := keyFromPrekeyAndSecret(decPrekey, secret)
block, err := aes.NewCipher(h[:])
if err != nil {
return nil, false
}
dec := cipher.NewCTR(block, decIV)
decrypted := make([]byte, len(handshake))
dec.XORKeyStream(decrypted, handshake)
protoTag := decrypted[protoTagPos : protoTagPos+4]
if !bytes.Equal(protoTag, protoTagAbridged) && !bytes.Equal(protoTag, protoTagIntermediate) && !bytes.Equal(protoTag, protoTagSecure) {
return nil, false
}
dcIdx := int16(binary.LittleEndian.Uint16(decrypted[dcIdxPos : dcIdxPos+2]))
dc := int(math.Abs(float64(dcIdx)))
isMedia := dcIdx < 0
pt := make([]byte, 4)
copy(pt, protoTag)
civ := make([]byte, len(decPrekeyAndIV))
copy(civ, decPrekeyAndIV)
return &handshakeInfo{DC: dc, IsMedia: isMedia, ProtoTag: pt, ClientDecI: civ}, true
}
func generateRelayInit(protoTag []byte, dcIdx int16) []byte {
rnd := make([]byte, handshakeLen)
for {
_, _ = rand.Read(rnd)
if reservedFirst[rnd[0]] {
continue
}
bad := false
for _, rs := range reservedStart {
if bytes.Equal(rnd[:4], rs) {
bad = true
break
}
}
if bad {
continue
}
if bytes.Equal(rnd[4:8], []byte{0, 0, 0, 0}) {
continue
}
break
}
encKey := rnd[skipLen : skipLen+prekeyLen]
encIV := rnd[skipLen+prekeyLen : skipLen+prekeyLen+ivLen]
block, _ := aes.NewCipher(encKey)
enc := cipher.NewCTR(block, encIV)
encryptedFull := make([]byte, handshakeLen)
enc.XORKeyStream(encryptedFull, rnd)
tailPlain := make([]byte, 8)
copy(tailPlain[:4], protoTag)
binary.LittleEndian.PutUint16(tailPlain[4:6], uint16(dcIdx))
_, _ = rand.Read(tailPlain[6:8])
result := make([]byte, handshakeLen)
copy(result, rnd)
for i := 0; i < 8; i++ {
keystream := encryptedFull[56+i] ^ rnd[56+i]
result[56+i] = tailPlain[i] ^ keystream
}
return result
}
func buildCiphers(clientDecPrekeyAndIV, relayInit, secret []byte) (cltDec, cltEnc, tgEnc, tgDec cipher.Stream, err error) {
cltDecPrekey := clientDecPrekeyAndIV[:prekeyLen]
cltDecIV := clientDecPrekeyAndIV[prekeyLen:]
k1 := keyFromPrekeyAndSecret(cltDecPrekey, secret)
b1, err := aes.NewCipher(k1[:])
if err != nil {
return nil, nil, nil, nil, err
}
cltDec = cipher.NewCTR(b1, cltDecIV)
rev := reverseBytes(clientDecPrekeyAndIV)
encPrekey := rev[:prekeyLen]
encIV := rev[prekeyLen:]
k2 := keyFromPrekeyAndSecret(encPrekey, secret)
b2, err := aes.NewCipher(k2[:])
if err != nil {
return nil, nil, nil, nil, err
}
cltEnc = cipher.NewCTR(b2, encIV)
relayEncKey := relayInit[8:40]
relayEncIV := relayInit[40:56]
b3, err := aes.NewCipher(relayEncKey)
if err != nil {
return nil, nil, nil, nil, err
}
tgEnc = cipher.NewCTR(b3, relayEncIV)
relayDecPI := reverseBytes(relayInit[8:56])
relayDecKey := relayDecPI[:keyLen]
relayDecIV := relayDecPI[keyLen:]
b4, err := aes.NewCipher(relayDecKey)
if err != nil {
return nil, nil, nil, nil, err
}
tgDec = cipher.NewCTR(b4, relayDecIV)
zeros := make([]byte, handshakeLen)
tmp := make([]byte, handshakeLen)
cltDec.XORKeyStream(tmp, zeros)
tgEnc.XORKeyStream(tmp, zeros)
return cltDec, cltEnc, tgEnc, tgDec, nil
}
func keyFromPrekeyAndSecret(prekey, secret []byte) [32]byte {
b := make([]byte, 0, len(prekey)+len(secret))
b = append(b, prekey...)
b = append(b, secret...)
return sha256.Sum256(b)
}
func reverseBytes(in []byte) []byte {
out := make([]byte, len(in))
for i := range in {
out[len(in)-1-i] = in[i]
}
return out
}
func protoFromTag(tag []byte) uint32 {
switch {
case bytes.Equal(tag, protoTagAbridged):
return protoAbridgedInt
case bytes.Equal(tag, protoTagIntermediate):
return protoIntermediateInt
default:
return protoPaddedIntermediateInt
}
}
func wsDomains(dc int, isMedia bool) []string {
dcS := strconv.Itoa(dc)
if isMedia {
return []string{
"kws" + dcS + "-1.web.telegram.org",
"kws" + dcS + ".web.telegram.org",
}
}
return []string{
"kws" + dcS + ".web.telegram.org",
"kws" + dcS + "-1.web.telegram.org",
}
}
func signedDC(dc int, media bool) int16 {
if media {
return int16(-dc)
}
return int16(dc)
}
func fallbackIP(dc int) string {
if ip, ok := dcFallbackDefaults[dc]; ok {
return ip
}
return ""
}

5
src/errors.go Normal file
View File

@ -0,0 +1,5 @@
package main
import "errors"
var errNoDomains = errors.New("no domains to connect")

5
src/go.mod Normal file
View File

@ -0,0 +1,5 @@
module tg-ws-proxy
go 1.22
require github.com/gorilla/websocket v1.5.3

2
src/go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

98
src/logging.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"os"
"sync"
)
func initLogger(cfg *Config) {
log.SetFlags(log.Ltime)
if cfg.LogFile == "" {
return
}
f, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Printf("WARN failed to open log file %s: %v", cfg.LogFile, err)
return
}
log.SetOutput(newRotatingWriter(f, cfg.LogFile, cfg.LogMaxMB, cfg.LogBackups))
}
func startPprof(cfg *Config) {
if cfg.PprofListen == "" {
return
}
go func(addr string) {
log.Printf("INFO pprof enabled on http://%s/debug/pprof/", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Printf("WARN pprof server stopped: %v", err)
}
}(cfg.PprofListen)
}
type rotatingWriter struct {
mu sync.Mutex
f *os.File
path string
maxBytes int64
backups int
checkTick int
}
func newRotatingWriter(f *os.File, path string, maxMB float64, backups int) *rotatingWriter {
maxBytes := int64(maxMB * 1024 * 1024)
if maxBytes < 32*1024 {
maxBytes = 32 * 1024
}
return &rotatingWriter{f: f, path: path, maxBytes: maxBytes, backups: backups}
}
func (w *rotatingWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.checkTick++
if w.checkTick%32 == 0 {
if st, err := w.f.Stat(); err == nil && st.Size() >= w.maxBytes {
_ = w.rotate()
}
}
return w.f.Write(p)
}
func (w *rotatingWriter) rotate() error {
_ = w.f.Close()
if w.backups > 0 {
for i := w.backups - 1; i >= 1; i-- {
old := fmt.Sprintf("%s.%d", w.path, i)
newp := fmt.Sprintf("%s.%d", w.path, i+1)
_ = os.Rename(old, newp)
}
_ = os.Rename(w.path, fmt.Sprintf("%s.1", w.path))
} else {
_ = os.Remove(w.path)
}
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
w.f = f
return nil
}
func debugf(cfg *Config, format string, args ...any) {
if cfg.Verbose {
log.Printf("DEBUG "+format, args...)
}
}
func warnf(format string, args ...any) {
log.Printf("WARNING "+format, args...)
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}

107
src/pool.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
type pooledWS struct {
Conn *websocket.Conn
Created time.Time
}
type wsPool struct {
mu sync.Mutex
idle map[dcKey][]pooledWS
refilling map[dcKey]bool
}
func newWSPool() *wsPool {
return &wsPool{
idle: make(map[dcKey][]pooledWS),
refilling: make(map[dcKey]bool),
}
}
func (p *wsPool) get(cfg *Config, key dcKey, targetIP string, domains []string, st *Stats) *websocket.Conn {
now := time.Now()
p.mu.Lock()
bucket := p.idle[key]
for len(bucket) > 0 {
item := bucket[0]
bucket = bucket[1:]
if now.Sub(item.Created) > wsPoolMaxAge {
_ = item.Conn.Close()
continue
}
p.idle[key] = bucket
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
atomic.AddInt64(&st.poolHits, 1)
return item.Conn
}
p.idle[key] = bucket
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
atomic.AddInt64(&st.poolMisses, 1)
return nil
}
func (p *wsPool) scheduleRefill(cfg *Config, key dcKey, targetIP string, domains []string) {
if cfg.PoolSize <= 0 || p.refilling[key] {
return
}
p.refilling[key] = true
go p.refill(cfg, key, targetIP, domains)
}
func (p *wsPool) refill(cfg *Config, key dcKey, targetIP string, domains []string) {
defer func() {
p.mu.Lock()
delete(p.refilling, key)
p.mu.Unlock()
}()
for {
p.mu.Lock()
cur := len(p.idle[key])
p.mu.Unlock()
if cur >= cfg.PoolSize {
return
}
conn, _, err := wsConnect(targetIP, domains, 8*time.Second)
if err != nil {
return
}
p.mu.Lock()
p.idle[key] = append(p.idle[key], pooledWS{Conn: conn, Created: time.Now()})
p.mu.Unlock()
}
}
func warmupPool(cfg *Config) {
if cfg.PoolSize <= 0 {
return
}
for dc, targets := range cfg.DCPool {
if len(targets) == 0 {
continue
}
ip := targets[0]
for _, media := range []bool{false, true} {
dcw := dc
if v, ok := dcOverrides[dcw]; ok {
dcw = v
}
key := dcKey{DC: dc, IsMedia: media}
domains := wsDomains(dcw, media)
pool.mu.Lock()
pool.scheduleRefill(cfg, key, ip, domains)
pool.mu.Unlock()
}
}
logf("INFO WS pool warmup started for %d DC(s)", len(cfg.DCMap))
}

268
src/server.go Normal file
View File

@ -0,0 +1,268 @@
package main
import (
"encoding/hex"
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
func main() {
cfg, err := parseFlags()
if err != nil {
log.Fatalf("config error: %v", err)
}
if cfg.GenSecret {
fmt.Println(cfg.SecretHex)
return
}
initLogger(cfg)
startPprof(cfg)
linkHost := getLinkHost(cfg.Host)
log.Printf("INFO %s", strings.Repeat("=", 60))
log.Printf("INFO Telegram MTProto WS Bridge Proxy (Go)")
log.Printf("INFO Listening on %s:%d", cfg.Host, cfg.Port)
log.Printf("INFO Secret: %s", cfg.SecretHex)
log.Printf("INFO Target DC IPs:")
for _, item := range sortedDCMap(cfg.DCMap) {
dc, ip := item.dc, item.ip
log.Printf("INFO DC%d: %s", dc, ip)
}
log.Printf("INFO %s", strings.Repeat("=", 60))
log.Printf("INFO Connect link:")
log.Printf("INFO tg://proxy?server=%s&port=%d&secret=dd%s", linkHost, cfg.Port, cfg.SecretHex)
log.Printf("INFO %s", strings.Repeat("=", 60))
go func() {
for {
time.Sleep(60 * time.Second)
log.Printf("INFO stats: %s", stats.summary())
}
}()
warmupPool(cfg)
ln, err := net.Listen("tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)))
if err != nil {
log.Fatalf("listen error: %v", err)
}
defer ln.Close()
secret, _ := hex.DecodeString(cfg.SecretHex)
sessionsSem := make(chan struct{}, cfg.MaxConns)
acceptBackoff := acceptBackoffMin
tcpLn, _ := ln.(*net.TCPListener)
for {
if tcpLn != nil {
_ = tcpLn.SetDeadline(time.Now().Add(acceptPollTimeout))
}
c, err := ln.Accept()
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
acceptBackoff = acceptBackoffMin
continue
}
log.Printf("WARN accept error: %v", err)
time.Sleep(acceptBackoff)
acceptBackoff *= 2
if acceptBackoff > acceptBackoffMax {
acceptBackoff = acceptBackoffMax
}
continue
}
acceptBackoff = acceptBackoffMin
atomic.AddInt64(&stats.connectionsTotal, 1)
select {
case sessionsSem <- struct{}{}:
go func(conn net.Conn) {
defer func() { <-sessionsSem }()
handleClient(conn, cfg, secret)
}(c)
default:
log.Printf("WARN max concurrent sessions reached (%d), dropping %s", cfg.MaxConns, c.RemoteAddr())
_ = c.Close()
}
}
}
func handleClient(client net.Conn, cfg *Config, secret []byte) {
atomic.AddInt64(&stats.connectionsActive, 1)
defer atomic.AddInt64(&stats.connectionsActive, -1)
defer client.Close()
label := client.RemoteAddr().String()
_ = setSockOpts(client, cfg.BufKB*1024)
_ = client.SetReadDeadline(time.Now().Add(10 * time.Second))
hs := make([]byte, handshakeLen)
if _, err := io.ReadFull(client, hs); err != nil {
debugf(cfg, "[%s] client disconnected before handshake", label)
return
}
_ = client.SetReadDeadline(time.Time{})
hi, ok := tryHandshake(hs, secret)
if !ok {
atomic.AddInt64(&stats.connectionsBad, 1)
debugf(cfg, "[%s] bad handshake", label)
return
}
protoInt := protoFromTag(hi.ProtoTag)
mediaTag := ""
if hi.IsMedia {
mediaTag = " media"
}
relayInit := generateRelayInit(hi.ProtoTag, signedDC(hi.DC, hi.IsMedia))
cltDec, cltEnc, tgEnc, tgDec, err := buildCiphers(hi.ClientDecI, relayInit, secret)
if err != nil {
log.Printf("ERROR [%s] cipher init failed: %v", label, err)
return
}
if isBlacklisted(hi.DC, hi.IsMedia) {
fallback := fallbackIP(hi.DC)
if fallback == "" {
log.Printf("WARN [%s] DC%d%s WS blacklisted and no fallback", label, hi.DC, mediaTag)
return
}
log.Printf("INFO [%s] DC%d%s WS blacklisted -> TCP fallback %s:443", label, hi.DC, mediaTag, fallback)
_ = tcpFallback(client, fallback, relayInit, cltDec, cltEnc, tgEnc, tgDec)
return
}
targets, hasTarget := cfg.DCPool[hi.DC]
if !hasTarget || len(targets) == 0 {
fallback := fallbackIP(hi.DC)
if fallback == "" {
log.Printf("WARN [%s] DC%d%s no fallback available", label, hi.DC, mediaTag)
return
}
log.Printf("INFO [%s] DC%d not in config -> TCP fallback %s:443", label, hi.DC, fallback)
_ = tcpFallback(client, fallback, relayInit, cltDec, cltEnc, tgEnc, tgDec)
return
}
primaryTarget := targets[0]
dcW := hi.DC
if v, ok := dcOverrides[dcW]; ok {
dcW = v
}
domains := wsDomains(dcW, hi.IsMedia)
key := dcKey{DC: hi.DC, IsMedia: hi.IsMedia}
connectWS := func(timeout time.Duration) (*websocket.Conn, bool, bool) {
wsFailedRedirect := false
allRedirect := true
for _, target := range targets {
for _, d := range domains {
debugf(cfg, "[%s] DC%d%s -> wss://%s/apiws via %s", label, hi.DC, mediaTag, d, target)
conn, resp, err := wsConnect(target, []string{d}, timeout)
if err == nil {
allRedirect = false
return conn, wsFailedRedirect, allRedirect
}
atomic.AddInt64(&stats.wsErrors, 1)
if resp != nil && isRedirect(resp.StatusCode) {
wsFailedRedirect = true
warnf("[%s] DC%d%s got %d from %s via %s", label, hi.DC, mediaTag, resp.StatusCode, d, target)
continue
}
allRedirect = false
warnf("[%s] DC%d%s WS connect failed via %s: %v", label, hi.DC, mediaTag, target, err)
}
}
return nil, wsFailedRedirect, allRedirect
}
fallbackToTCP := func(wsFailedRedirect, allRedirect bool) {
if wsFailedRedirect && allRedirect {
setBlacklisted(key)
warnf("[%s] DC%d%s blacklisted for WS (all redirects)", label, hi.DC, mediaTag)
} else {
setCooldown(key)
}
fallback := fallbackIP(hi.DC)
if fallback == "" {
fallback = primaryTarget
}
log.Printf("INFO [%s] DC%d%s -> TCP fallback to %s:443", label, hi.DC, mediaTag, fallback)
err := tcpFallback(client, fallback, relayInit, cltDec, cltEnc, tgEnc, tgDec)
if err == nil {
log.Printf("INFO [%s] DC%d%s TCP fallback closed", label, hi.DC, mediaTag)
}
}
var ws *websocket.Conn
fromPool := false
if pooled := pool.get(cfg, key, primaryTarget, domains, &stats); pooled != nil {
ws = pooled
fromPool = true
log.Printf("INFO [%s] DC%d%s -> pool hit via %s", label, hi.DC, mediaTag, primaryTarget)
}
if ws == nil {
timeout := 10 * time.Second
if inCooldown(key) {
timeout = 2 * time.Second
}
wsFailedRedirect := false
allRedirect := true
ws, wsFailedRedirect, allRedirect = connectWS(timeout)
if ws == nil {
fallbackToTCP(wsFailedRedirect, allRedirect)
return
}
}
var splitter *msgSplitter
if ms, err := newMsgSplitter(relayInit, protoInt); err == nil {
splitter = ms
}
if err := ws.WriteMessage(websocket.BinaryMessage, relayInit); err != nil {
warnf("[%s] ws init write failed: %v", label, err)
_ = ws.Close()
if !fromPool {
setCooldown(key)
fallbackToTCP(false, false)
return
}
timeout := 10 * time.Second
if inCooldown(key) {
timeout = 2 * time.Second
}
wsFailedRedirect := false
allRedirect := true
ws, wsFailedRedirect, allRedirect = connectWS(timeout)
if ws == nil {
fallbackToTCP(wsFailedRedirect, allRedirect)
return
}
if err := ws.WriteMessage(websocket.BinaryMessage, relayInit); err != nil {
warnf("[%s] ws init write failed after pool retry: %v", label, err)
_ = ws.Close()
setCooldown(key)
fallbackToTCP(false, false)
return
}
}
clearCooldown(key)
atomic.AddInt64(&stats.connectionsWS, 1)
bridgeWS(label, hi.DC, hi.IsMedia, client, ws, cltDec, cltEnc, tgEnc, tgDec, splitter)
}

122
src/splitter.go Normal file
View File

@ -0,0 +1,122 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"encoding/binary"
)
type msgSplitter struct {
dec cipher.Stream
proto uint32
cipherBuf []byte
plainBuf []byte
disabled bool
}
func newMsgSplitter(relayInit []byte, proto uint32) (*msgSplitter, error) {
b, err := aes.NewCipher(relayInit[8:40])
if err != nil {
return nil, err
}
dec := cipher.NewCTR(b, relayInit[40:56])
zero := make([]byte, handshakeLen)
tmp := make([]byte, handshakeLen)
dec.XORKeyStream(tmp, zero)
return &msgSplitter{dec: dec, proto: proto}, nil
}
func (m *msgSplitter) split(chunk []byte) [][]byte {
if len(chunk) == 0 {
return nil
}
if m.disabled {
return [][]byte{chunk}
}
m.cipherBuf = append(m.cipherBuf, chunk...)
plainStart := len(m.plainBuf)
m.plainBuf = append(m.plainBuf, make([]byte, len(chunk))...)
m.dec.XORKeyStream(m.plainBuf[plainStart:], chunk)
parts := make([][]byte, 0, 2)
for len(m.cipherBuf) > 0 {
next := m.nextPacketLen()
if next < 0 {
break
}
if next == 0 {
parts = append(parts, m.cipherBuf)
m.cipherBuf = m.cipherBuf[:0]
m.plainBuf = m.plainBuf[:0]
m.disabled = true
break
}
parts = append(parts, m.cipherBuf[:next])
m.cipherBuf = m.cipherBuf[next:]
m.plainBuf = m.plainBuf[next:]
}
return parts
}
func (m *msgSplitter) flush() [][]byte {
if len(m.cipherBuf) == 0 {
return nil
}
tail := m.cipherBuf
m.cipherBuf = m.cipherBuf[:0]
m.plainBuf = m.plainBuf[:0]
return [][]byte{tail}
}
func (m *msgSplitter) nextPacketLen() int {
if len(m.plainBuf) == 0 {
return -1
}
switch m.proto {
case protoAbridgedInt:
return m.nextAbridgedLen()
case protoIntermediateInt, protoPaddedIntermediateInt:
return m.nextIntermediateLen()
default:
return 0
}
}
func (m *msgSplitter) nextAbridgedLen() int {
first := m.plainBuf[0]
headerLen := 1
payloadLen := 0
if first == 0x7F || first == 0xFF {
if len(m.plainBuf) < 4 {
return -1
}
headerLen = 4
payloadLen = int(uint32(m.plainBuf[1])|uint32(m.plainBuf[2])<<8|uint32(m.plainBuf[3])<<16) * 4
} else {
payloadLen = int(first&0x7F) * 4
}
if payloadLen <= 0 {
return 0
}
packetLen := headerLen + payloadLen
if len(m.plainBuf) < packetLen {
return -1
}
return packetLen
}
func (m *msgSplitter) nextIntermediateLen() int {
if len(m.plainBuf) < 4 {
return -1
}
payloadLen := int(binary.LittleEndian.Uint32(m.plainBuf[:4]) & 0x7FFFFFFF)
if payloadLen <= 0 {
return 0
}
packetLen := 4 + payloadLen
if len(m.plainBuf) < packetLen {
return -1
}
return packetLen
}

59
src/state.go Normal file
View File

@ -0,0 +1,59 @@
package main
import (
"sort"
"sync"
"time"
)
var (
stats Stats
pool = newWSPool()
blacklist = make(map[dcKey]struct{})
blMu sync.Mutex
failUntil = make(map[dcKey]time.Time)
fuMu sync.Mutex
)
func setCooldown(k dcKey) {
fuMu.Lock()
failUntil[k] = time.Now().Add(dcFailCooldown)
fuMu.Unlock()
}
func inCooldown(k dcKey) bool {
fuMu.Lock()
t, ok := failUntil[k]
fuMu.Unlock()
return ok && time.Now().Before(t)
}
func clearCooldown(k dcKey) {
fuMu.Lock()
delete(failUntil, k)
fuMu.Unlock()
}
func setBlacklisted(k dcKey) {
blMu.Lock()
blacklist[k] = struct{}{}
blMu.Unlock()
}
func isBlacklisted(dc int, media bool) bool {
blMu.Lock()
_, ok := blacklist[dcKey{DC: dc, IsMedia: media}]
blMu.Unlock()
return ok
}
func sortedDCMap(m map[int]string) []dcMapItem {
items := make([]dcMapItem, 0, len(m))
for dc, ip := range m {
items = append(items, dcMapItem{dc: dc, ip: ip})
}
sort.Slice(items, func(i, j int) bool {
return items[i].dc < items[j].dc
})
return items
}

261
src/transport.go Normal file
View File

@ -0,0 +1,261 @@
package main
import (
"context"
"crypto/cipher"
"crypto/tls"
"net"
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
var ioBufPool = sync.Pool{
New: func() any {
return make([]byte, 64*1024)
},
}
func dialWS(targetIP, domain string, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
u := url.URL{Scheme: "wss", Host: domain, Path: "/apiws"}
dialer := websocket.Dialer{
HandshakeTimeout: timeout,
Subprotocols: []string{"binary"},
TLSClientConfig: &tls.Config{
ServerName: domain,
InsecureSkipVerify: true,
},
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
d := &net.Dialer{Timeout: timeout}
return d.DialContext(ctx, "tcp", net.JoinHostPort(targetIP, "443"))
},
}
headers := http.Header{}
headers.Set("Host", domain)
headers.Set("Origin", "https://web.telegram.org")
headers.Set("User-Agent", "Mozilla/5.0")
return dialer.Dial(u.String(), headers)
}
func bridgeWS(label string, dc int, isMedia bool, client net.Conn, ws *websocket.Conn, cltDec, cltEnc, tgEnc, tgDec cipher.Stream, splitter *msgSplitter) {
mediaTag := ""
if isMedia {
mediaTag = "m"
}
start := time.Now()
var upBytes, downBytes int64
var upPkts, downPkts int64
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
buf := ioBufPool.Get().([]byte)
defer ioBufPool.Put(buf)
var upPending int64
defer func() {
if upPending > 0 {
atomic.AddInt64(&stats.bytesUp, upPending)
}
}()
for {
_ = client.SetReadDeadline(time.Now().Add(ioIdleTimeout))
n, err := client.Read(buf)
if n > 0 {
upPending += int64(n)
upBytes += int64(n)
upPkts++
if upPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesUp, upPending)
upPending = 0
}
chunk := buf[:n]
cltDec.XORKeyStream(chunk, chunk)
tgEnc.XORKeyStream(chunk, chunk)
if splitter != nil {
parts := splitter.split(chunk)
if len(parts) == 0 {
continue
}
for _, p := range parts {
_ = ws.SetWriteDeadline(time.Now().Add(wsWriteTimeout))
if werr := ws.WriteMessage(websocket.BinaryMessage, p); werr != nil {
return
}
}
} else {
_ = ws.SetWriteDeadline(time.Now().Add(wsWriteTimeout))
if werr := ws.WriteMessage(websocket.BinaryMessage, chunk); werr != nil {
return
}
}
}
if err != nil {
if splitter != nil {
for _, p := range splitter.flush() {
_ = ws.SetWriteDeadline(time.Now().Add(wsWriteTimeout))
_ = ws.WriteMessage(websocket.BinaryMessage, p)
}
}
return
}
}
}()
go func() {
defer func() { done <- struct{}{} }()
var downPending int64
defer func() {
if downPending > 0 {
atomic.AddInt64(&stats.bytesDown, downPending)
}
}()
for {
_ = ws.SetReadDeadline(time.Now().Add(ioIdleTimeout))
mt, data, err := ws.ReadMessage()
if err != nil {
return
}
if mt != websocket.BinaryMessage {
continue
}
n := int64(len(data))
downPending += n
downBytes += n
downPkts++
if downPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesDown, downPending)
downPending = 0
}
tgDec.XORKeyStream(data, data)
cltEnc.XORKeyStream(data, data)
_ = client.SetWriteDeadline(time.Now().Add(ioIdleTimeout))
if _, werr := client.Write(data); werr != nil {
return
}
}
}()
<-done
_ = ws.Close()
_ = client.Close()
logf("INFO [%s] DC%d%s WS session closed: ^%s (%d pkts) v%s (%d pkts) in %.1fs",
label,
dc,
mediaTag,
humanBytes(upBytes),
upPkts,
humanBytes(downBytes),
downPkts,
time.Since(start).Seconds(),
)
}
func tcpFallback(client net.Conn, dst string, relayInit []byte, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) error {
r, err := net.DialTimeout("tcp", net.JoinHostPort(dst, "443"), 10*time.Second)
if err != nil {
logf("WARNING TCP fallback to %s:443 failed: %v", dst, err)
return err
}
defer r.Close()
atomic.AddInt64(&stats.connectionsTCP, 1)
if _, err := r.Write(relayInit); err != nil {
return err
}
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
buf := ioBufPool.Get().([]byte)
defer ioBufPool.Put(buf)
var upPending int64
defer func() {
if upPending > 0 {
atomic.AddInt64(&stats.bytesUp, upPending)
}
}()
for {
_ = client.SetReadDeadline(time.Now().Add(ioIdleTimeout))
n, err := client.Read(buf)
if n > 0 {
upPending += int64(n)
if upPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesUp, upPending)
upPending = 0
}
chunk := buf[:n]
cltDec.XORKeyStream(chunk, chunk)
tgEnc.XORKeyStream(chunk, chunk)
_ = r.SetWriteDeadline(time.Now().Add(ioIdleTimeout))
if _, werr := r.Write(chunk); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
go func() {
defer func() { done <- struct{}{} }()
buf := ioBufPool.Get().([]byte)
defer ioBufPool.Put(buf)
var downPending int64
defer func() {
if downPending > 0 {
atomic.AddInt64(&stats.bytesDown, downPending)
}
}()
for {
_ = r.SetReadDeadline(time.Now().Add(ioIdleTimeout))
n, err := r.Read(buf)
if n > 0 {
downPending += int64(n)
if downPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesDown, downPending)
downPending = 0
}
chunk := buf[:n]
tgDec.XORKeyStream(chunk, chunk)
cltEnc.XORKeyStream(chunk, chunk)
_ = client.SetWriteDeadline(time.Now().Add(ioIdleTimeout))
if _, werr := client.Write(chunk); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
<-done
_ = client.Close()
_ = r.Close()
return nil
}
func wsConnect(targetIP string, domains []string, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
var lastErr error
var lastResp *http.Response
for _, domain := range domains {
conn, resp, err := dialWS(targetIP, domain, timeout)
if err == nil {
return conn, resp, nil
}
lastErr = err
lastResp = resp
}
if lastErr == nil {
lastErr = errNoDomains
}
return nil, lastResp, lastErr
}

75
src/types.go Normal file
View File

@ -0,0 +1,75 @@
package main
import (
"fmt"
"sync/atomic"
)
type Config struct {
Host string
Port int
SecretHex string
GenSecret bool
DCMap map[int]string
DCPool map[int][]string
Verbose bool
BufKB int
PoolSize int
MaxConns int
LogFile string
LogMaxMB float64
LogBackups int
PprofListen string
}
type Stats struct {
connectionsTotal int64
connectionsActive int64
connectionsWS int64
connectionsTCP int64
connectionsBad int64
wsErrors int64
bytesUp int64
bytesDown int64
poolHits int64
poolMisses int64
}
func (s *Stats) summary() string {
hits := atomic.LoadInt64(&s.poolHits)
misses := atomic.LoadInt64(&s.poolMisses)
poolTotal := hits + misses
poolS := "n/a"
if poolTotal > 0 {
poolS = fmt.Sprintf("%d/%d", hits, poolTotal)
}
return fmt.Sprintf(
"total=%d active=%d ws=%d tcp_fb=%d bad=%d err=%d pool=%s up=%s down=%s",
atomic.LoadInt64(&s.connectionsTotal),
atomic.LoadInt64(&s.connectionsActive),
atomic.LoadInt64(&s.connectionsWS),
atomic.LoadInt64(&s.connectionsTCP),
atomic.LoadInt64(&s.connectionsBad),
atomic.LoadInt64(&s.wsErrors),
poolS,
humanBytes(atomic.LoadInt64(&s.bytesUp)),
humanBytes(atomic.LoadInt64(&s.bytesDown)),
)
}
type handshakeInfo struct {
DC int
IsMedia bool
ProtoTag []byte
ClientDecI []byte
}
type dcKey struct {
DC int
IsMedia bool
}
type dcMapItem struct {
dc int
ip string
}

53
src/utils.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"fmt"
"math"
"net"
)
func formatFloat(v float64) string {
return fmt.Sprintf("%.1f", v)
}
func humanBytes(n int64) string {
v := float64(n)
units := []string{"B", "KB", "MB", "GB", "TB"}
u := 0
for math.Abs(v) >= 1024 && u < len(units)-1 {
v /= 1024
u++
}
return formatFloat(v) + units[u]
}
func getLinkHost(host string) string {
if host != "0.0.0.0" {
return host
}
c, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "127.0.0.1"
}
defer c.Close()
la, ok := c.LocalAddr().(*net.UDPAddr)
if !ok || la.IP == nil {
return "127.0.0.1"
}
return la.IP.String()
}
func isRedirect(code int) bool {
return code == 301 || code == 302 || code == 303 || code == 307 || code == 308
}
func setSockOpts(c net.Conn, bufSize int) error {
tcp, ok := c.(*net.TCPConn)
if !ok {
return nil
}
_ = tcp.SetNoDelay(true)
_ = tcp.SetReadBuffer(bufSize)
_ = tcp.SetWriteBuffer(bufSize)
return nil
}

View File

@ -1,865 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import base64
import logging
import os
import socket as _socket
import ssl
import struct
import sys
import time
from typing import Dict, List, Optional, Set, Tuple
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
DEFAULT_PORT = 1080
DEFAULT_TARGET_IP = '149.154.167.220' # unthrottled, works for DC2 and DC4
log = logging.getLogger('tg-ws-proxy')
_TG_RANGES = [
# 185.76.151.0/24
(struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0],
struct.unpack('!I', _socket.inet_aton('185.76.151.255'))[0]),
# 149.154.160.0/20
(struct.unpack('!I', _socket.inet_aton('149.154.160.0'))[0],
struct.unpack('!I', _socket.inet_aton('149.154.175.255'))[0]),
# 91.105.192.0/23
(struct.unpack('!I', _socket.inet_aton('91.105.192.0'))[0],
struct.unpack('!I', _socket.inet_aton('91.105.193.255'))[0]),
# 91.108.0.0/16
(struct.unpack('!I', _socket.inet_aton('91.108.0.0'))[0],
struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]),
]
_dc_opt: Dict[int, Optional[str]] = {}
# DCs where WS is known to fail (302 redirect)
# Raw TCP fallback will be used instead
# Keyed by (dc, is_media)
_ws_blacklist: Set[Tuple[int, bool]] = set()
# Rate-limit re-attempts per (dc, is_media)
_dc_fail_until: Dict[Tuple[int, bool], float] = {}
_DC_FAIL_COOLDOWN = 60.0 # seconds
_ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE
class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str,
headers: dict = None, location: str = None):
self.status_code = status_code
self.status_line = status_line
self.headers = headers or {}
self.location = location
super().__init__(f"HTTP {status_code}: {status_line}")
@property
def is_redirect(self) -> bool:
return self.status_code in (301, 302, 303, 307, 308)
def _xor_mask(data: bytes, mask: bytes) -> bytes:
if not data:
return data
a = bytearray(data)
for i in range(len(a)):
a[i] ^= mask[i & 3]
return bytes(a)
class RawWebSocket:
"""
Lightweight WebSocket client over asyncio reader/writer streams.
Connects DIRECTLY to a target IP via TCP+TLS (bypassing any system
proxy), performs the HTTP Upgrade handshake, and provides send/recv
for binary frames with proper masking, ping/pong, and close handling.
"""
OP_CONTINUATION = 0x0
OP_TEXT = 0x1
OP_BINARY = 0x2
OP_CLOSE = 0x8
OP_PING = 0x9
OP_PONG = 0xA
def __init__(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
self.reader = reader
self.writer = writer
self._closed = False
@staticmethod
async def connect(ip: str, domain: str, path: str = '/apiws',
timeout: float = 10.0) -> 'RawWebSocket':
"""
Connect via TLS to the given IP,
perform WebSocket upgrade, return a RawWebSocket.
Raises WsHandshakeError on non-101 response.
"""
reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, 443, ssl=_ssl_ctx,
server_hostname=domain),
timeout=min(timeout, 10))
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f'GET {path} HTTP/1.1\r\n'
f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n'
f'Origin: https://web.telegram.org\r\n'
f'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
f'AppleWebKit/537.36 (KHTML, like Gecko) '
f'Chrome/131.0.0.0 Safari/537.36\r\n'
f'\r\n'
)
writer.write(req.encode())
await writer.drain()
# Read HTTP response headers line-by-line so the reader stays
# positioned right at the start of WebSocket frames.
response_lines: list[str] = []
try:
while True:
line = await asyncio.wait_for(reader.readline(),
timeout=timeout)
if line in (b'\r\n', b'\n', b''):
break
response_lines.append(
line.decode('utf-8', errors='replace').strip())
except asyncio.TimeoutError:
writer.close()
raise
if not response_lines:
writer.close()
raise WsHandshakeError(0, 'empty response')
first_line = response_lines[0]
parts = first_line.split(' ', 2)
try:
status_code = int(parts[1]) if len(parts) >= 2 else 0
except ValueError:
status_code = 0
if status_code == 101:
return RawWebSocket(reader, writer)
headers: dict[str, str] = {}
for hl in response_lines[1:]:
if ':' in hl:
k, v = hl.split(':', 1)
headers[k.strip().lower()] = v.strip()
writer.close()
raise WsHandshakeError(status_code, first_line, headers,
location=headers.get('location'))
async def send(self, data: bytes):
"""Send a masked binary WebSocket frame."""
if self._closed:
raise ConnectionError("WebSocket closed")
frame = self._build_frame(self.OP_BINARY, data, mask=True)
self.writer.write(frame)
await self.writer.drain()
async def recv(self) -> Optional[bytes]:
"""
Receive the next data frame. Handles ping/pong/close
internally. Returns payload bytes, or None on clean close.
"""
while not self._closed:
opcode, payload = await self._read_frame()
if opcode == self.OP_CLOSE:
self._closed = True
try:
reply = self._build_frame(
self.OP_CLOSE,
payload[:2] if payload else b'',
mask=True)
self.writer.write(reply)
await self.writer.drain()
except Exception:
pass
return None
if opcode == self.OP_PING:
try:
pong = self._build_frame(self.OP_PONG, payload,
mask=True)
self.writer.write(pong)
await self.writer.drain()
except Exception:
pass
continue
if opcode == self.OP_PONG:
continue
if opcode in (self.OP_TEXT, self.OP_BINARY):
return payload
# Unknown opcode — skip
continue
return None
async def close(self):
"""Send close frame and shut down the transport."""
if self._closed:
return
self._closed = True
try:
self.writer.write(
self._build_frame(self.OP_CLOSE, b'', mask=True))
await self.writer.drain()
except Exception:
pass
try:
self.writer.close()
await self.writer.wait_closed()
except Exception:
pass
@staticmethod
def _build_frame(opcode: int, data: bytes,
mask: bool = False) -> bytes:
header = bytearray()
header.append(0x80 | opcode) # FIN=1 + opcode
length = len(data)
mask_bit = 0x80 if mask else 0x00
if length < 126:
header.append(mask_bit | length)
elif length < 65536:
header.append(mask_bit | 126)
header.extend(struct.pack('>H', length))
else:
header.append(mask_bit | 127)
header.extend(struct.pack('>Q', length))
if mask:
mask_key = os.urandom(4)
header.extend(mask_key)
return bytes(header) + _xor_mask(data, mask_key)
return bytes(header) + data
async def _read_frame(self) -> Tuple[int, bytes]:
hdr = await self.reader.readexactly(2)
opcode = hdr[0] & 0x0F
is_masked = bool(hdr[1] & 0x80)
length = hdr[1] & 0x7F
if length == 126:
length = struct.unpack('>H',
await self.reader.readexactly(2))[0]
elif length == 127:
length = struct.unpack('>Q',
await self.reader.readexactly(8))[0]
if is_masked:
mask_key = await self.reader.readexactly(4)
payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key)
payload = await self.reader.readexactly(length)
return opcode, payload
def _human_bytes(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'):
if abs(n) < 1024:
return f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}TB"
def _is_telegram_ip(ip: str) -> bool:
try:
n = struct.unpack('!I', _socket.inet_aton(ip))[0]
return any(lo <= n <= hi for lo, hi in _TG_RANGES)
except OSError:
return False
def _is_http_transport(data: bytes) -> bool:
return (data[:5] == b'POST ' or data[:4] == b'GET ' or
data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ')
def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]:
"""
Extract DC ID from the 64-byte MTProto obfuscation init packet.
Returns (dc_id, is_media).
"""
try:
key = bytes(data[8:40])
iv = bytes(data[40:56])
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
encryptor = cipher.encryptor()
keystream = encryptor.update(b'\x00' * 64) + encryptor.finalize()
plain = bytes(a ^ b for a, b in zip(data[56:64], keystream[56:64]))
proto = struct.unpack('<I', plain[0:4])[0]
dc_raw = struct.unpack('<h', plain[4:6])[0]
log.debug("dc_from_init: proto=0x%08X dc_raw=%d plain=%s",
proto, dc_raw, plain.hex())
if proto in (0xEFEFEFEF, 0xEEEEEEEE, 0xDDDDDDDD):
dc = abs(dc_raw)
if 1 <= dc <= 1000:
return dc, (dc_raw < 0)
except Exception as exc:
log.debug("DC extraction failed: %s", exc)
return None, False
def _ws_domains(dc: int, is_media) -> List[str]:
"""
Return domain names to try for WebSocket connection to a DC.
DC 1-5: kws{N}[-1].web.telegram.org
DC >5: kws{N}[-1].telegram.org
"""
base = 'telegram.org' if dc > 5 else 'web.telegram.org'
if is_media is None:
return [f'kws{dc}-1.{base}', f'kws{dc}.{base}']
if is_media:
return [f'kws{dc}-1.{base}', f'kws{dc}.{base}']
return [f'kws{dc}.{base}', f'kws{dc}-1.{base}']
class Stats:
def __init__(self):
self.connections_total = 0
self.connections_ws = 0
self.connections_tcp_fallback = 0
self.connections_http_rejected = 0
self.connections_passthrough = 0
self.ws_errors = 0
self.bytes_up = 0
self.bytes_down = 0
def summary(self) -> str:
return (f"total={self.connections_total} ws={self.connections_ws} "
f"tcp_fb={self.connections_tcp_fallback} "
f"http_skip={self.connections_http_rejected} "
f"pass={self.connections_passthrough} "
f"err={self.ws_errors} "
f"up={_human_bytes(self.bytes_up)} "
f"down={_human_bytes(self.bytes_down)}")
_stats = Stats()
async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
dc=None, dst=None, port=None, is_media=False):
"""Bidirectional TCP <-> WebSocket forwarding."""
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
dst_tag = f"{dst}:{port}" if dst else "?"
up_bytes = 0
down_bytes = 0
up_packets = 0
down_packets = 0
start_time = asyncio.get_event_loop().time()
async def tcp_to_ws():
nonlocal up_bytes, up_packets
try:
while True:
chunk = await reader.read(65536)
if not chunk:
break
_stats.bytes_up += len(chunk)
up_bytes += len(chunk)
up_packets += 1
await ws.send(chunk)
except (asyncio.CancelledError, ConnectionError, OSError):
return
except Exception as e:
log.debug("[%s] tcp->ws ended: %s", label, e)
async def ws_to_tcp():
nonlocal down_bytes, down_packets
try:
while True:
data = await ws.recv()
if data is None:
break
_stats.bytes_down += len(data)
down_bytes += len(data)
down_packets += 1
writer.write(data)
await writer.drain()
except (asyncio.CancelledError, ConnectionError, OSError):
return
except Exception as e:
log.debug("[%s] ws->tcp ended: %s", label, e)
tasks = [asyncio.create_task(tcp_to_ws()),
asyncio.create_task(ws_to_tcp())]
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except BaseException:
pass
elapsed = asyncio.get_event_loop().time() - start_time
log.info("[%s] %s (%s) WS session closed: "
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
label, dc_tag, dst_tag,
_human_bytes(up_bytes), up_packets,
_human_bytes(down_bytes), down_packets,
elapsed)
try:
await ws.close()
except BaseException:
pass
try:
writer.close()
await writer.wait_closed()
except BaseException:
pass
async def _bridge_tcp(reader, writer, remote_reader, remote_writer,
label, dc=None, dst=None, port=None,
is_media=False):
"""Bidirectional TCP <-> TCP forwarding (for fallback)."""
async def forward(src, dst_w, tag):
try:
while True:
data = await src.read(65536)
if not data:
break
if 'up' in tag:
_stats.bytes_up += len(data)
else:
_stats.bytes_down += len(data)
dst_w.write(data)
await dst_w.drain()
except asyncio.CancelledError:
pass
except Exception as e:
log.debug("[%s] %s ended: %s", label, tag, e)
tasks = [
asyncio.create_task(forward(reader, remote_writer, 'up')),
asyncio.create_task(forward(remote_reader, writer, 'down')),
]
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except BaseException:
pass
for w in (writer, remote_writer):
try:
w.close()
await w.wait_closed()
except BaseException:
pass
async def _pipe(r, w):
"""Plain TCP relay for non-Telegram traffic."""
try:
while True:
data = await r.read(65536)
if not data:
break
w.write(data)
await w.drain()
except asyncio.CancelledError:
pass
except Exception:
pass
finally:
try:
w.close()
await w.wait_closed()
except Exception:
pass
def _socks5_reply(status):
return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6
async def _tcp_fallback(reader, writer, dst, port, init, label,
dc=None, is_media=False):
"""
Fall back to direct TCP to the original DC IP.
Throttled by ISP, but functional. Returns True on success.
"""
try:
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
except Exception as exc:
log.warning("[%s] TCP fallback connect to %s:%d failed: %s",
label, dst, port, exc)
return False
_stats.connections_tcp_fallback += 1
rw.write(init)
await rw.drain()
await _bridge_tcp(reader, writer, rr, rw, label,
dc=dc, dst=dst, port=port, is_media=is_media)
return True
async def _handle_client(reader, writer):
_stats.connections_total += 1
peer = writer.get_extra_info('peername')
label = f"{peer[0]}:{peer[1]}" if peer else "?"
try:
# -- SOCKS5 greeting --
hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10)
if hdr[0] != 5:
log.debug("[%s] not SOCKS5 (ver=%d)", label, hdr[0])
writer.close()
return
nmethods = hdr[1]
await reader.readexactly(nmethods)
writer.write(b'\x05\x00') # no-auth
await writer.drain()
# -- SOCKS5 CONNECT request --
req = await asyncio.wait_for(reader.readexactly(4), timeout=10)
_ver, cmd, _rsv, atyp = req
if cmd != 1:
writer.write(_socks5_reply(0x07))
await writer.drain()
writer.close()
return
if atyp == 1: # IPv4
raw = await reader.readexactly(4)
dst = _socket.inet_ntoa(raw)
elif atyp == 3: # domain
dlen = (await reader.readexactly(1))[0]
dst = (await reader.readexactly(dlen)).decode()
elif atyp == 4: # IPv6
raw = await reader.readexactly(16)
dst = _socket.inet_ntop(_socket.AF_INET6, raw)
else:
writer.write(_socks5_reply(0x08))
await writer.drain()
writer.close()
return
port = struct.unpack('!H', await reader.readexactly(2))[0]
# -- Non-Telegram IP -> direct passthrough --
if not _is_telegram_ip(dst):
_stats.connections_passthrough += 1
log.debug("[%s] passthrough -> %s:%d", label, dst, port)
try:
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
except Exception as exc:
log.warning("[%s] passthrough failed: %s", label, exc)
writer.write(_socks5_reply(0x05))
await writer.drain()
writer.close()
return
writer.write(_socks5_reply(0x00))
await writer.drain()
tasks = [asyncio.create_task(_pipe(reader, rw)),
asyncio.create_task(_pipe(rr, writer))]
await asyncio.wait(tasks,
return_when=asyncio.FIRST_COMPLETED)
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except BaseException:
pass
return
# -- Telegram DC: accept SOCKS, read init --
writer.write(_socks5_reply(0x00))
await writer.drain()
try:
init = await asyncio.wait_for(
reader.readexactly(64), timeout=15)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before init", label)
return
# HTTP transport -> reject
if _is_http_transport(init):
_stats.connections_http_rejected += 1
log.debug("[%s] HTTP transport to %s:%d (rejected)",
label, dst, port)
writer.close()
return
# -- Extract DC ID --
dc, is_media = _dc_from_init(init)
if dc is None or dc not in _dc_opt:
log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough",
label, dc, dst, port)
await _tcp_fallback(reader, writer, dst, port, init, label)
return
dc_key = (dc, is_media if is_media is not None else True)
now = time.monotonic()
media_tag = (" media" if is_media
else (" media?" if is_media is None else ""))
# -- WS blacklist check --
if dc_key in _ws_blacklist:
log.debug("[%s] DC%d%s WS blacklisted -> TCP %s:%d",
label, dc, media_tag, dst, port)
ok = await _tcp_fallback(reader, writer, dst, port, init,
label, dc=dc, is_media=is_media)
if ok:
log.info("[%s] DC%d%s TCP fallback closed",
label, dc, media_tag)
return
# -- Cooldown check --
fail_until = _dc_fail_until.get(dc_key, 0)
if now < fail_until:
remaining = fail_until - now
log.debug("[%s] DC%d%s WS cooldown (%.0fs) -> TCP",
label, dc, media_tag, remaining)
ok = await _tcp_fallback(reader, writer, dst, port, init,
label, dc=dc, is_media=is_media)
if ok:
log.info("[%s] DC%d%s TCP fallback closed",
label, dc, media_tag)
return
# -- Try WebSocket via direct connection --
domains = _ws_domains(dc, is_media)
target = _dc_opt[dc]
ws = None
ws_failed_redirect = False
all_redirects = True
for domain in domains:
url = f'wss://{domain}/apiws'
log.info("[%s] DC%d%s (%s:%d) -> %s via %s",
label, dc, media_tag, dst, port, url, target)
try:
ws = await RawWebSocket.connect(target, domain,
timeout=10)
all_redirects = False
break
except WsHandshakeError as exc:
_stats.ws_errors += 1
if exc.is_redirect:
ws_failed_redirect = True
log.warning("[%s] DC%d%s got %d from %s -> %s",
label, dc, media_tag,
exc.status_code, domain,
exc.location or '?')
continue
else:
all_redirects = False
log.warning("[%s] DC%d%s WS handshake: %s",
label, dc, media_tag, exc.status_line)
except Exception as exc:
_stats.ws_errors += 1
all_redirects = False
err_str = str(exc)
if ('CERTIFICATE_VERIFY_FAILED' in err_str or
'Hostname mismatch' in err_str):
log.warning("[%s] DC%d%s SSL error: %s",
label, dc, media_tag, exc)
else:
log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, exc)
# -- WS failed -> fallback --
if ws is None:
if ws_failed_redirect and all_redirects:
_ws_blacklist.add(dc_key)
log.warning(
"[%s] DC%d%s blacklisted for WS (all 302)",
label, dc, media_tag)
elif ws_failed_redirect:
_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",
label, dc, media_tag, int(_DC_FAIL_COOLDOWN))
log.info("[%s] DC%d%s -> TCP fallback to %s:%d",
label, dc, media_tag, dst, port)
ok = await _tcp_fallback(reader, writer, dst, port, init,
label, dc=dc, is_media=is_media)
if ok:
log.info("[%s] DC%d%s TCP fallback closed",
label, dc, media_tag)
return
# -- WS success --
_dc_fail_until.pop(dc_key, None)
_stats.connections_ws += 1
# Send the buffered init packet
await ws.send(init)
# Bidirectional bridge
await _bridge_ws(reader, writer, ws, label,
dc=dc, dst=dst, port=port, is_media=is_media)
except asyncio.TimeoutError:
log.warning("[%s] timeout during SOCKS5 handshake", label)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected", label)
except asyncio.CancelledError:
log.debug("[%s] cancelled", label)
except ConnectionResetError:
log.debug("[%s] connection reset", label)
except Exception as exc:
log.error("[%s] unexpected: %s", label, exc)
finally:
try:
writer.close()
except BaseException:
pass
_server_instance = None
_server_stop_event = None
async def _run(port: int, dc_opt: Dict[int, Optional[str]],
stop_event: Optional[asyncio.Event] = None):
global _dc_opt, _server_instance, _server_stop_event
_dc_opt = dc_opt
_server_stop_event = stop_event
server = await asyncio.start_server(
_handle_client, '127.0.0.1', port)
_server_instance = server
log.info("=" * 60)
log.info(" Telegram WS Bridge Proxy")
log.info(" Listening on 127.0.0.1:%d", port)
log.info(" Target DC IPs:")
for dc in dc_opt.keys():
ip = dc_opt.get(dc)
log.info(" DC%d: %s", dc, ip)
log.info("=" * 60)
log.info(" Configure Telegram Desktop:")
log.info(" SOCKS5 proxy -> 127.0.0.1:%d (no user/pass)", port)
log.info("=" * 60)
async def log_stats():
while True:
await asyncio.sleep(60)
bl = ', '.join(
f'DC{d}{"m" if m else ""}'
for d, m in sorted(_ws_blacklist)) or 'none'
log.info("stats: %s | ws_bl: %s", _stats.summary(), bl)
asyncio.create_task(log_stats())
if stop_event:
async def wait_stop():
await stop_event.wait()
server.close()
me = asyncio.current_task()
for task in list(asyncio.all_tasks()):
if task is not me:
task.cancel()
try:
await server.wait_closed()
except asyncio.CancelledError:
pass
asyncio.create_task(wait_stop())
async with server:
try:
await server.serve_forever()
except asyncio.CancelledError:
pass
_server_instance = None
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
"""Parse list of 'DC:IP' strings into {dc: ip} dict."""
dc_opt: Dict[int, str] = {}
for entry in dc_ip_list:
if ':' not in entry:
raise ValueError(f"Invalid --dc-ip format {entry!r}, expected DC:IP")
dc_s, ip_s = entry.split(':', 1)
try:
dc_n = int(dc_s)
_socket.inet_aton(ip_s)
except (ValueError, OSError):
raise ValueError(f"Invalid --dc-ip {entry!r}")
dc_opt[dc_n] = ip_s
return dc_opt
def run_proxy(port: int, dc_opt: Dict[int, str],
stop_event: Optional[asyncio.Event] = None):
"""Run the proxy (blocking). Can be called from threads."""
asyncio.run(_run(port, dc_opt, stop_event))
def main():
ap = argparse.ArgumentParser(
description='Telegram Desktop WebSocket Bridge Proxy')
ap.add_argument('--port', type=int, default=DEFAULT_PORT,
help=f'Listen port (default {DEFAULT_PORT})')
ap.add_argument('--dc-ip', metavar='DC:IP', action='append',
default=['2:149.154.167.220', '4:149.154.167.220'],
help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205'
' --dc-ip 2:149.154.167.220')
ap.add_argument('-v', '--verbose', action='store_true',
help='Debug logging')
args = ap.parse_args()
try:
dc_opt = parse_dc_ip_list(args.dc_ip)
except ValueError as e:
log.error(str(e))
sys.exit(1)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format='%(asctime)s %(levelname)-5s %(message)s',
datefmt='%H:%M:%S',
)
try:
asyncio.run(_run(args.port, dc_opt))
except KeyboardInterrupt:
log.info("Shutting down. Final stats: %s", _stats.summary())
if __name__ == '__main__':
main()

View File

@ -1,64 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
a = Analysis(
['tg_ws_tray.py'],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/')],
hiddenimports=[
'tg_ws_proxy',
'pystray._win32',
'PIL._tkinter_finder',
'customtkinter',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
icon_path = os.path.join(os.path.dirname(SPEC), 'icon.ico')
if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')]
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='TgWsProxy',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=icon_path if os.path.exists(icon_path) else None,
)

View File

@ -1,604 +0,0 @@
from __future__ import annotations
import ctypes
import json
import logging
import os
import psutil
import sys
import threading
import time
import webbrowser
import asyncio as _asyncio
from pathlib import Path
from typing import Dict, List, Optional
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = ImageDraw = ImageFont = None # type: ignore
try:
import pystray
except ImportError:
pystray = None # type: ignore
try:
import customtkinter as ctk
except ImportError:
ctk = None # type: ignore
# Proxy engine
import tg_ws_proxy
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
DEFAULT_CONFIG = {
"port": 1080,
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
_proxy_thread: Optional[threading.Thread] = None
_stop_event: Optional[threading.Event] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
log = logging.getLogger("tg-ws-tray")
def is_already_running():
current_proc = os.path.basename(sys.argv[0])
count = 0
for process in psutil.process_iter(['name']):
if process.info['name'] == current_proc:
count += 1
return count > 2
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Merge with defaults for missing keys
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S"))
root.addHandler(ch)
def _make_icon_image(size: int = 64):
"""Create a simple tray icon: blue circle with a white 'T' letter."""
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Blue circle
margin = 2
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 136, 204, 255))
# White "T"
try:
font = ImageFont.truetype("arial.ttf", size=int(size * 0.55))
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon():
"""Load icon from file or generate one."""
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool):
"""Target for the proxy thread — runs asyncio event loop."""
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _config
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on port %d ...", port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose),
daemon=True, name="proxy")
_proxy_thread.start()
def stop_proxy():
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
ctypes.windll.user32.MessageBoxW(0, text, title, 0x10)
def _show_info(text: str, title: str = "TG WS Proxy"):
ctypes.windll.user32.MessageBoxW(0, text, title, 0x40)
def _on_open_in_telegram(icon=None, item=None):
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server=127.0.0.1&port={port}"
log.info("Opening %s", url)
try:
result = webbrowser.open(url)
if not result:
raise RuntimeError("webbrowser.open returned False")
except Exception:
log.info("Browser open failed, copying to clipboard")
try:
_copy_to_clipboard(url)
_show_info(
f"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в телеграмм и нажмите по ней ЛКМ:\n{url}",
"TG WS Proxy")
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _copy_to_clipboard(text: str):
"""Copy text to Windows clipboard using ctypes."""
import ctypes.wintypes
CF_UNICODETEXT = 13
kernel32 = ctypes.windll.kernel32
user32 = ctypes.windll.user32
user32.OpenClipboard(0)
user32.EmptyClipboard()
encoded = text.encode("utf-16-le") + b"\x00\x00"
h = kernel32.GlobalAlloc(0x0042, len(encoded)) # GMEM_MOVEABLE | GMEM_ZEROINIT
p = kernel32.GlobalLock(h)
ctypes.memmove(p, encoded, len(encoded))
kernel32.GlobalUnlock(h)
user32.SetClipboardData(CF_UNICODETEXT, h)
user32.CloseClipboard()
def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start()
def _on_edit_config(icon=None, item=None):
"""Open a simple dialog to edit config."""
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog():
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")
root = ctk.CTk()
root.title("TG WS Proxy — Настройки")
root.resizable(False, False)
root.attributes("-topmost", True)
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Segoe UI"
w, h = 420, 400
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=24, pady=20)
# Port
ctk.CTkLabel(frame, text="Порт прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
port_var = ctk.StringVar(value=str(cfg.get("port", 1080)))
port_entry = ctk.CTkEntry(frame, textvariable=port_var, width=120, height=36,
font=(FONT_FAMILY, 13), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
port_entry.pack(anchor="w", pady=(0, 12))
# DC-IP mappings
ctk.CTkLabel(frame, text="DC → IP маппинги (по одному на строку, формат DC:IP)",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(frame, width=370, height=120,
font=("Consolas", 12), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
dc_textbox.pack(anchor="w", pady=(0, 12))
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])))
# Verbose
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
ctk.CTkCheckBox(frame, text="Подробное логирование (verbose)",
variable=verbose_var, font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
corner_radius=6, border_width=2,
border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8))
# Info label
ctk.CTkLabel(frame, text="Изменения вступят в силу после перезапуска прокси.",
font=(FONT_FAMILY, 11), text_color=TEXT_SECONDARY,
anchor="w").pack(anchor="w", pady=(0, 16))
def on_save():
try:
port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
_show_error("Порт должен быть числом 1-65535")
return
lines = [l.strip() for l in dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
_show_error(str(e))
return
new_cfg = {
"port": port_val,
"dc_ip": lines,
"verbose": verbose_var.get(),
}
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
from tkinter import messagebox
if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n"
"Перезапустить прокси сейчас?",
parent=root):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x")
ctk.CTkButton(btn_frame, text="Сохранить", width=140, height=38,
font=(FONT_FAMILY, 14, "bold"), corner_radius=10,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_save).pack(side="left", padx=(0, 10))
ctk.CTkButton(btn_frame, text="Отмена", width=140, height=38,
font=(FONT_FAMILY, 14), corner_radius=10,
fg_color=FIELD_BG, hover_color=FIELD_BORDER,
text_color=TEXT_PRIMARY, border_width=1,
border_color=FIELD_BORDER,
command=on_cancel).pack(side="left")
root.mainloop()
def _on_open_logs(icon=None, item=None):
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
os.startfile(str(LOG_FILE))
else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy")
def _on_exit(icon=None, item=None):
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
def _force_exit():
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
if icon:
icon.stop()
def _show_first_run():
_ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server=127.0.0.1&port={port}"
if ctk is None:
FIRST_RUN_MARKER.touch()
return
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Segoe UI"
root = ctk.CTk()
root.title("TG WS Proxy")
root.resizable(False, False)
root.attributes("-topmost", True)
w, h = 520, 440
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=28, pady=24)
title_frame = ctk.CTkFrame(frame, fg_color="transparent")
title_frame.pack(anchor="w", pady=(0, 16), fill="x")
# Blue accent bar
accent_bar = ctk.CTkFrame(title_frame, fg_color=TG_BLUE,
width=4, height=32, corner_radius=2)
accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее",
font=(FONT_FAMILY, 17, "bold"),
text_color=TEXT_PRIMARY).pack(side="left")
# Info sections
sections = [
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(f" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → 127.0.0.1 : {port} (без логина/пароля)", False),
]
for text, bold in sections:
weight = "bold" if bold else "normal"
ctk.CTkLabel(frame, text=text,
font=(FONT_FAMILY, 13, weight),
text_color=TEXT_PRIMARY,
anchor="w", justify="left").pack(anchor="w", pady=1)
# Spacer
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
# Separator
ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1,
corner_radius=0).pack(fill="x", pady=(0, 12))
# Checkbox
auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас",
variable=auto_var, font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
corner_radius=6, border_width=2,
border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 16))
def on_ok():
FIRST_RUN_MARKER.touch()
open_tg = auto_var.get()
root.destroy()
if open_tg:
_on_open_in_telegram()
ctk.CTkButton(frame, text="Начать", width=180, height=42,
font=(FONT_FAMILY, 15, "bold"), corner_radius=10,
fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_ok).pack(pady=(0, 0))
root.protocol("WM_DELETE_WINDOW", on_ok)
root.mainloop()
def _build_menu():
if pystray is None:
return None
port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu(
pystray.MenuItem(
f"Открыть в Telegram (:{port})",
_on_open_in_telegram,
default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit),
)
def run_tray():
global _tray_icon, _config
_config = load_config()
save_config(_config)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(_config.get("verbose", False))
log.info("TG WS Proxy tray app starting")
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
if pystray is None or Image is None:
log.error("pystray or Pillow not installed; "
"running in console mode")
start_proxy()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
stop_proxy()
return
start_proxy()
_show_first_run()
icon_image = _load_icon()
_tray_icon = pystray.Icon(
APP_NAME,
icon_image,
"TG WS Proxy",
menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
stop_proxy()
log.info("Tray app exited")
def main():
if is_already_running():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
# Hide console window if running as frozen exe
if getattr(sys, "frozen", False):
try:
ctypes.windll.user32.ShowWindow(
ctypes.windll.kernel32.GetConsoleWindow(), 0)
except Exception:
pass
run_tray()
if __name__ == "__main__":
main()