63 Commits
v1.1.0 ... 0.2

Author SHA1 Message Date
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
33 changed files with 2007 additions and 2145 deletions

View File

@ -1,102 +1,160 @@
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
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- arch: aarch64
pkg_arch: aarch64-3.10
goarch: arm64
goarm: ""
gomips: ""
- arch: mipsel
pkg_arch: mipsel-3.4
goarch: mipsle
goarm: ""
gomips: softfloat
- arch: mips
pkg_arch: mips-3.4
goarch: mips
goarm: ""
gomips: softfloat
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v5
- 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: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y dos2unix
- name: Install pyinstaller
run: pip install pyinstaller
- name: Resolve project directory
id: project-dir
run: |
if [ -f tg-ws-proxy/Makefile ]; then
echo "dir=tg-ws-proxy" >> "$GITHUB_OUTPUT"
elif [ -f Makefile ] && [ -d common ] && [ -d .github/workflows ]; then
echo "dir=." >> "$GITHUB_OUTPUT"
else
echo "Could not find tg-ws-proxy project directory"
echo "Repository root content:"
ls -la
exit 1
fi
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Build package
run: |
GOARCH_INPUT="${{ matrix.goarch }}"
GOARM_INPUT="${{ matrix.goarm }}"
GOMIPS_INPUT="${{ matrix.gomips }}"
PKG_ARCH_INPUT="${{ matrix.pkg_arch }}"
PROJECT_DIR="${{ steps.project-dir.outputs.dir }}"
GO_PROXY_PATH="src"
VERSION_INPUT="$(cat "$PROJECT_DIR/VERSION")"
if [ -z "$VERSION_INPUT" ]; then
echo "VERSION file is empty: $PROJECT_DIR/VERSION"
exit 1
fi
if [ ! -d "$PROJECT_DIR/$GO_PROXY_PATH" ]; then
echo "src directory not found in project root"
echo "Expected: $PROJECT_DIR/$GO_PROXY_PATH"
ls -la "$PROJECT_DIR"
exit 1
fi
make -C "$PROJECT_DIR" tg-ws-proxy-ipk PKG_ARCH="$PKG_ARCH_INPUT" GO_PROXY_DIR="$GO_PROXY_PATH" GOARCH="$GOARCH_INPUT" GOARM="$GOARM_INPUT" GOMIPS="$GOMIPS_INPUT"
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: TgWsProxy
path: |
dist/TgWsProxy.exe
name: tg-ws-proxy-${{ matrix.arch }}
path: ${{ steps.project-dir.outputs.dir }}/out/tg-ws-proxy_*_${{ matrix.pkg_arch }}.ipk
if-no-files-found: error
build-win7:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.8 (last version supporting Win7)
uses: actions/setup-python@v5
with:
python-version: "3.8"
cache: "pip"
- name: Install dependencies (Win7-compatible)
run: pip install -r requirements-win7.txt
- name: Install pyinstaller
run: pip install "pyinstaller==5.13.2"
- name: Build EXE with PyInstaller (Win7)
run: pyinstaller packaging/windows.spec --noconfirm
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy-win7.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy-win7
path: dist/TgWsProxy-win7.exe
release:
needs: [build, build-win7]
publish-latest-release:
runs-on: ubuntu-latest
needs: [ build ]
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Download main build
uses: actions/download-artifact@v4
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
name: TgWsProxy
path: dist
path: out
merge-multiple: true
- name: Download Win7 build
uses: actions/download-artifact@v4
- name: Resolve latest target release
id: target-release
uses: actions/github-script@v8
with:
name: TgWsProxy-win7
path: dist
script: |
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
- name: Create GitHub 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
dist/TgWsProxy-win7.exe
draft: false
prerelease: false
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.

90
Makefile Normal file
View File

@ -0,0 +1,90 @@
SHELL := /bin/bash
PACKAGE := tg-ws-proxy
ROOT_DIR := /opt
DEPENDENCIES := ca-certificates xxd
GOOS ?= linux
GOARCH ?= arm64
GOARM ?=
GOMIPS ?=
CGO_ENABLED ?= 0
GO_PROXY_DIR ?= src
GO_BIN_NAME ?= tg-ws-proxy
GO_BIN ?= out/$(GO_BIN_NAME)-$(GOARCH)
DEFAULT_PKG_ARCH := $(GOARCH)
ifeq ($(GOARCH),amd64)
DEFAULT_PKG_ARCH := x64
endif
ifeq ($(GOARCH),arm64)
DEFAULT_PKG_ARCH := aarch64
endif
ifeq ($(GOARCH),arm)
ifeq ($(GOARM),7)
DEFAULT_PKG_ARCH := armv7
else
DEFAULT_PKG_ARCH := arm
endif
endif
ifeq ($(GOARCH),mipsle)
DEFAULT_PKG_ARCH := mipsel
endif
PKG_ARCH ?= $(DEFAULT_PKG_ARCH)
VERSION := $(shell cat VERSION)
.PHONY: clean _build-go _pkg-clean _pkg-control _pkg-scripts _pkg-data _pkg-ipk tg-ws-proxy-ipk
clean:
rm -rf out
_build-go:
mkdir -p out
cd "$(GO_PROXY_DIR)" && \
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) GOMIPS=$(GOMIPS) CGO_ENABLED=$(CGO_ENABLED) \
go build -o "$(abspath $(GO_BIN))" .
echo "$(VERSION)" > out/VERSION
_pkg-clean:
rm -rf out/pkg
mkdir -p out/pkg/control
mkdir -p out/pkg/data
_pkg-control:
version="$$(cat out/VERSION)"; \
echo "Package: $(PACKAGE)" > out/pkg/control/control; \
echo "Version: $$version-1" >> out/pkg/control/control; \
echo "Depends: $(DEPENDENCIES)" >> out/pkg/control/control; \
echo "Section: net" >> out/pkg/control/control; \
echo "Architecture: $(PKG_ARCH)" >> out/pkg/control/control; \
echo "License: MIT" >> out/pkg/control/control; \
echo "Description: Telegram MTProto WS bridge proxy (Go binary)" >> out/pkg/control/control
_pkg-scripts:
cp common/ipk/prerm out/pkg/control/prerm
cp common/ipk/postinst out/pkg/control/postinst
cp common/ipk/postrm out/pkg/control/postrm
cp common/ipk/conffiles out/pkg/control/conffiles
find out/pkg/control -type f -print0 | xargs -0 dos2unix
chmod +x out/pkg/control/prerm out/pkg/control/postinst out/pkg/control/postrm
_pkg-data:
mkdir -p out/pkg/data$(ROOT_DIR)/etc/init.d
mkdir -p out/pkg/data$(ROOT_DIR)/etc
mkdir -p out/pkg/data$(ROOT_DIR)/bin
cp "$(GO_BIN)" out/pkg/data$(ROOT_DIR)/bin/tg-ws-proxy
cat common/tg-ws-proxy-common.sh > out/pkg/data$(ROOT_DIR)/etc/init.d/S61tg-ws-proxy
awk '/^start\(\)/{p=1} p{print}' common/S61tg-ws-proxy >> out/pkg/data$(ROOT_DIR)/etc/init.d/S61tg-ws-proxy
cp common/tg-ws-proxy.conf out/pkg/data$(ROOT_DIR)/etc/tg-ws-proxy.conf
find out/pkg/data -type f -print0 | xargs -0 dos2unix
chmod +x out/pkg/data$(ROOT_DIR)/bin/tg-ws-proxy
chmod +x out/pkg/data$(ROOT_DIR)/etc/init.d/S61tg-ws-proxy
_pkg-ipk: _pkg-clean _pkg-control _pkg-scripts _pkg-data
cd out/pkg/control; tar czf ../control.tar.gz .; cd ../../..
cd out/pkg/data; tar czf ../data.tar.gz .; cd ../../..
echo 2.0 > out/pkg/debian-binary
version="$$(cat out/VERSION)"; \
cd out/pkg; tar czf ../$(PACKAGE)_$$version-1_$(PKG_ARCH).ipk control.tar.gz data.tar.gz debian-binary; cd ../..
tg-ws-proxy-ipk: _build-go _pkg-ipk
@echo "Built: out/$(PACKAGE)_$$(cat out/VERSION)-1_$(PKG_ARCH).ipk"

141
README.md
View File

@ -1,115 +1,76 @@
# TG WS Proxy
# TG WS Proxy Go (KeeneticOS)
Локальный 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
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-соединение
### Config
## 🚀 Быстрый старт
Main config file:
### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
**Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
## Установка из исходников
```bash
pip install -r requirements.txt
```shell
/opt/etc/tg-ws-proxy.conf
```
### Windows (Tray-приложение)
Minimal config example:
```bash
python windows.py
```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:
```bash
python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
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:
```conf
# Per-DC pool override (DC2)
EXTRA_ARGS="--dc-ip-pool 2:149.154.175.50,149.154.167.220"
# Per-DC single IP override (DC203) + verbose logs
EXTRA_ARGS="--dc-ip 203:91.105.192.100 -v"
```
**Аргументы:**
### Run
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
```bash
# Стандартный запуск
python proxy/tg_ws_proxy.py
# Другой порт и дополнительные DC
python proxy/tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
python proxy/tg_ws_proxy.py -v
```shell
/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 Desktop
### Logs
### Автоматически
If `LOG_LEVEL=1`, service logs are written to:
ПКМ по иконке в трее → **«Открыть в Telegram»**
### Вручную
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
## Конфигурация
Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`:
```json
{
"port": 1080,
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false
}
```shell
/opt/var/log/tg-ws-proxy.log
```
## Автоматическая сборка
### Remove
Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
```bash
pip install pyinstaller
pyinstaller packaging/windows.spec
```shell
opkg remove tg-ws-proxy
```
## Лицензия
[MIT License](LICENSE)
### Remove repository
```shell
rm /opt/etc/opkg/feedly.conf
```

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.2

107
common/S61tg-ws-proxy Normal file
View File

@ -0,0 +1,107 @@
#!/bin/sh
COMMON_FILE=/opt/etc/init.d/tg-ws-proxy-common.sh
if [ ! -f "$COMMON_FILE" ]; then
echo "Missing common script: $COMMON_FILE" >&2
exit 1
fi
. "$COMMON_FILE"
start() {
[ -x "$BIN" ] || return 1
mkdir -p /opt/var/run /opt/var/log
if [ ! -f "$CONFFILE" ]; then
echo "Config file not found: $CONFFILE" >&2
return 1
fi
if [ -z "$SECRET" ]; then
echo "SECRET is empty in $CONFFILE" >&2
return 1
fi
if [ -z "$DC_IP_DEFAULT" ]; then
echo "DC_IP_DEFAULT is empty in $CONFFILE" >&2
return 1
fi
cleanup_stale_pid
if is_running; then
echo "tg-ws-proxy already running"
return 0
fi
if [ "$LOG_LEVEL" = "1" ]; then
"$BIN" --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
"$BIN" --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
echo $! > "$PIDFILE"
i=0
while [ "$i" -lt 5 ]; do
if is_running; then
echo "tg-ws-proxy started"
print_connect_link || true
return 0
fi
i=$((i + 1))
sleep 1
done
echo "tg-ws-proxy failed to start" >&2
cleanup_stale_pid
if [ -f "$LOGFILE" ]; then
tail -n 20 "$LOGFILE" >&2 || true
fi
return 1
}
stop() {
cleanup_stale_pid
if ! is_running; then
echo "tg-ws-proxy is not running"
return 0
fi
pid="$(get_pid)"
kill "$pid" 2>/dev/null || true
i=0
while [ "$i" -lt 5 ]; do
if ! kill -0 "$pid" 2>/dev/null; then
rm -f "$PIDFILE"
echo "tg-ws-proxy stopped"
return 0
fi
i=$((i + 1))
sleep 1
done
kill -9 "$pid" 2>/dev/null || true
rm -f "$PIDFILE"
echo "tg-ws-proxy force stopped"
return 0
}
status() {
cleanup_stale_pid
if is_running; then
echo "tg-ws-proxy is running (pid $(get_pid))"
print_connect_link || true
return 0
fi
echo "tg-ws-proxy is not running"
return 1
}
restart() {
stop
start
}
case "$1" in
start) start ;;
stop) stop ;;
restart) restart ;;
status) status ;;
*) echo "Usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac

1
common/ipk/conffiles Normal file
View File

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

27
common/ipk/postinst Normal file
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="$(head -c 16 /dev/urandom | xxd -ps 2>/dev/null | tr -d ' \r\n')"
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

9
common/ipk/postrm Normal file
View File

@ -0,0 +1,9 @@
#!/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
echo "tg-ws-proxy removed"

13
common/ipk/prerm Normal file
View File

@ -0,0 +1,13 @@
#!/bin/sh
INIT_SCRIPT="/opt/etc/init.d/S61tg-ws-proxy"
stop_func() {
if [ -f "$INIT_SCRIPT" ]; then
"$INIT_SCRIPT" stop
fi
}
stop_func
exit 0

View File

@ -0,0 +1,104 @@
#!/bin/sh
PATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin
PIDFILE=/opt/var/run/tg-ws-proxy.pid
LOGFILE=/opt/var/log/tg-ws-proxy.log
BIN=/opt/bin/tg-ws-proxy
CONFFILE=/opt/etc/tg-ws-proxy.conf
if [ -f "$CONFFILE" ]; then
. "$CONFFILE"
fi
get_pid() {
[ -f "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
case "$pid" in
''|*[!0-9]*) return 1 ;;
esac
echo "$pid"
}
is_running() {
pid_saved="$(get_pid)" || return 1
if ! kill -0 "$pid_saved" 2>/dev/null; then
return 1
fi
cmdline_file="/proc/$pid_saved/cmdline"
[ -r "$cmdline_file" ] || return 1
cmdline="$(tr '\000' ' ' < "$cmdline_file")"
case "$cmdline" in
*"$BIN"*) return 0 ;;
*) return 1 ;;
esac
}
cleanup_stale_pid() {
if [ -f "$PIDFILE" ] && ! is_running; then
rm -f "$PIDFILE"
fi
}
load_runtime_args_from_pid() {
pid="$(get_pid)" || return 1
cmdline_file="/proc/$pid/cmdline"
[ -r "$cmdline_file" ] || return 1
rt_host=""
rt_port=""
rt_secret=""
set -- $(tr '\000' ' ' < "$cmdline_file")
while [ "$#" -gt 0 ]; do
case "$1" in
--host)
rt_host="$2"
shift 2
;;
--port)
rt_port="$2"
shift 2
;;
--secret)
rt_secret="$2"
shift 2
;;
*)
shift
;;
esac
done
RT_HOST="$rt_host"
RT_PORT="$rt_port"
RT_SECRET="$rt_secret"
return 0
}
print_connect_link() {
link_host="$HOST"
link_port="$PORT"
link_secret="$SECRET"
if is_running && load_runtime_args_from_pid; then
[ -n "$RT_HOST" ] && link_host="$RT_HOST"
[ -n "$RT_PORT" ] && link_port="$RT_PORT"
[ -n "$RT_SECRET" ] && link_secret="$RT_SECRET"
fi
if [ -z "$link_secret" ]; then
echo "SECRET is empty in $CONFFILE" >&2
return 1
fi
if [ "$link_host" = "0.0.0.0" ]; then
br0_ip="$(ip -f inet addr show dev br0 2>/dev/null | sed -n 's/.*inet \([0-9.]\+\)\/.*/\1/p' | head -n 1)"
[ -n "$br0_ip" ] && link_host="$br0_ip"
fi
echo "Connect link:"
echo " tg://proxy?server=$link_host&port=$link_port&secret=dd$link_secret"
}

16
common/tg-ws-proxy.conf Normal file
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=""

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -1,63 +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(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/')],
hiddenimports=[
'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), os.pardir, '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,
)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
cryptography==41.0.7
customtkinter==5.2.2
Pillow==10.4.0
psutil==5.9.8
pystray==0.19.5
pyperclip==1.9.0

View File

@ -1,6 +0,0 @@
cryptography==46.0.5
customtkinter==5.2.2
Pillow==12.1.1
psutil==7.0.0
pystray==0.19.5
pyperclip==1.9.0

164
src/config.go Normal file
View File

@ -0,0 +1,164 @@
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)")
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)
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,
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))
}

263
src/server.go Normal file
View File

@ -0,0 +1,263 @@
package main
import (
"encoding/hex"
"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)
}
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
}

74
src/types.go Normal file
View File

@ -0,0 +1,74 @@
package main
import (
"fmt"
"sync/atomic"
)
type Config struct {
Host string
Port int
SecretHex string
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,699 +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 pystray
import pyperclip
import asyncio as _asyncio
import customtkinter as ctk
from pathlib import Path
from typing import Dict, Optional
from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as 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"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray")
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return os.path.basename(sys.executable) == proc.name()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
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)
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):
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)
margin = 2
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 136, 204, 255))
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():
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,
host: str = '127.0.0.1'):
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, host=host))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "10048" in str(exc) or "Address already in use" in str(exc):
_show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.")
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"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
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 %s:%d ...", host, port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
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:
pyperclip.copy(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 _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start()
def _on_edit_config(icon=None, item=None):
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, 480
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)
# Host
ctk.CTkLabel(frame, text="IP-адрес прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1"))
host_entry = ctk.CTkEntry(frame, textvariable=host_var, width=200, height=36,
font=(FONT_FAMILY, 13), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
host_entry.pack(anchor="w", pady=(0, 12))
# 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():
import socket as _sock
host_val = host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
_show_error("Некорректный IP-адрес.")
return
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 = {
"host": host_val,
"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)
_tray_icon.menu = _build_menu()
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
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&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 → {host} : {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 _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включён IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах видны ошибки связанные с IPv6, "
"то проверьте в настройках Telegram, что рядом с настройкой прокси не включён "
"пунукт про IPv6. Если это не поможет, то выключите IPv6 в системе\n\n"
"Это предупрждение будет показано только один раз.",
"TG WS Proxy")
def _build_menu():
if pystray is None:
return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu(
pystray.MenuItem(
f"Открыть в Telegram ({host}:{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()
_check_ipv6_warning()
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 not _acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
try:
run_tray()
finally:
_release_lock()
if __name__ == "__main__":
main()