27 Commits
v1.2.1 ... 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
39 changed files with 2001 additions and 4711 deletions

View File

@ -1,20 +0,0 @@
name: 🐛 Проблема
title: '[Проблема] '
description: Сообщить о проблеме
labels: ['type: проблема', 'status: нуждается в сортировке']
body:
- type: textarea
id: description
attributes:
label: Опишите вашу проблему
description: Чётко опишите проблему с которой вы столкнулись
placeholder: Описание проблемы
validations:
required: true
- type: textarea
id: additions
attributes:
label: Дополнительные детали
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы.

View File

@ -1,364 +1,160 @@
name: Build & Release
name: Build tg-ws-proxy
on:
workflow_dispatch:
inputs:
make_release:
description: 'Create Github Release?'
type: boolean
required: true
default: false
version:
description: "Release version tag (e.g. v1.0.0)"
required: false
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 ".[win10]"
- name: Install pyinstaller
run: pip install "pyinstaller==6.13.0"
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy
path: |
dist/TgWsProxy_windows.exe
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 ".[win7]"
- 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_windows_7.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy-win7
path: dist/TgWsProxy_windows_7.exe
build-macos:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install universal2 Python
- name: Install build tools
run: |
set -euo pipefail
curl -LO https://www.python.org/ftp/python/3.12.10/python-3.12.10-macos11.pkg
sudo installer -pkg python-3.12.10-macos11.pkg -target /
echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> "$GITHUB_PATH"
sudo apt-get update
sudo apt-get install -y dos2unix
- name: Install dependencies
- name: Resolve project directory
id: project-dir
run: |
set -euo pipefail
python3.12 -m pip install --upgrade pip setuptools wheel
python3.12 -m pip install delocate==0.13.0
mkdir -p wheelhouse/arm64 wheelhouse/x86_64 wheelhouse/universal2
python3.12 -m pip download \
--only-binary=:all: \
--platform macosx_11_0_arm64 \
--python-version 3.12 \
--implementation cp \
-d wheelhouse/arm64 \
'cffi>=2.0.0' \
Pillow==12.1.0 \
psutil==7.0.0
python3.12 -m pip download \
--only-binary=:all: \
--platform macosx_10_13_x86_64 \
--python-version 3.12 \
--implementation cp \
-d wheelhouse/x86_64 \
'cffi>=2.0.0' \
Pillow==12.1.0
python3.12 -m pip download \
--only-binary=:all: \
--platform macosx_10_9_x86_64 \
--python-version 3.12 \
--implementation cp \
-d wheelhouse/x86_64 \
psutil==7.0.0
delocate-merge \
wheelhouse/arm64/cffi-*.whl \
wheelhouse/x86_64/cffi-*.whl \
-w wheelhouse/universal2
delocate-merge \
wheelhouse/arm64/pillow-12.1.0-*.whl \
wheelhouse/x86_64/pillow-12.1.0-*.whl \
-w wheelhouse/universal2
delocate-merge \
wheelhouse/arm64/psutil-7.0.0-*.whl \
wheelhouse/x86_64/psutil-7.0.0-*.whl \
-w wheelhouse/universal2
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
python3.12 -m pip install ".[macos]"
python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO
run: |
set -euo pipefail
python3.12 - <<'PY'
from PIL import Image
image = Image.open('icon.ico')
image = image.resize((1024, 1024), Image.LANCZOS)
image.save('icon_1024.png', 'PNG')
PY
mkdir -p icon.iconset
sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png
sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png
sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png
sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png
sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png
sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png
sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png
sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png
sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png
sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png
iconutil -c icns icon.iconset -o icon.icns
rm -rf icon.iconset icon_1024.png
- name: Build app with PyInstaller
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
- name: Validate universal2 app bundle
run: |
set -euo pipefail
found=0
while IFS= read -r -d '' file; do
if file "$file" | grep -q "Mach-O"; then
found=1
archs="$(lipo -archs "$file" 2>/dev/null || true)"
case "$archs" in
*arm64*x86_64*|*x86_64*arm64*) ;;
*)
echo "Missing universal2 slices in $file: ${archs:-unknown}" >&2
exit 1
;;
esac
fi
done < <(find "dist/TG WS Proxy.app" -type f -print0)
if [ "$found" -eq 0 ]; then
echo "No Mach-O files found in app bundle" >&2
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: Create DMG
- name: Build package
run: |
set -euo pipefail
APP_NAME="TG WS Proxy"
DMG_TEMP="dist/dmg_temp"
rm -rf "$DMG_TEMP"
mkdir -p "$DMG_TEMP"
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
ln -s /Applications "$DMG_TEMP/Applications"
hdiutil create \
-volname "$APP_NAME" \
-srcfolder "$DMG_TEMP" \
-ov \
-format UDZO \
"dist/TgWsProxy_macos_universal.dmg"
rm -rf "$DMG_TEMP"
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-macOS
path: dist/TgWsProxy_macos_universal.dmg
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-linux:
publish-latest-release:
runs-on: ubuntu-latest
needs: [ build ]
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
python3-venv \
python3-dev \
python3-gi \
gir1.2-ayatanaappindicator3-0.1 \
python3-tk
- name: Create venv with system site-packages
run: python3 -m venv --system-site-packages .venv
- name: Install dependencies
run: |
.venv/bin/pip install --upgrade pip
.venv/bin/pip install ".[linux]"
.venv/bin/pip install "pyinstaller==6.13.0"
- name: Build binary with PyInstaller
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
- name: Rename binary artifact
run: mv dist/TgWsProxy dist/TgWsProxy_linux_amd64
- name: Create .deb package
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.version }}"
VERSION="${VERSION#v}"
PKG_ROOT="pkg"
rm -rf "$PKG_ROOT"
mkdir -p \
"$PKG_ROOT/DEBIAN" \
"$PKG_ROOT/usr/bin" \
"$PKG_ROOT/usr/share/applications" \
"$PKG_ROOT/usr/share/icons/hicolor/256x256/apps"
install -m 755 dist/TgWsProxy_linux_amd64 "$PKG_ROOT/usr/bin/tg-ws-proxy"
.venv/bin/python - <<PY
from PIL import Image
Image.open("icon.ico").save(
"${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
"PNG",
)
PY
cat > "$PKG_ROOT/usr/share/applications/tg-ws-proxy.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=TG WS Proxy
GenericName=Telegram Proxy
Comment=Telegram Desktop WebSocket Bridge Proxy
Exec=tg-ws-proxy
Icon=tg-ws-proxy
Terminal=false
Categories=Network;
StartupNotify=true
Keywords=telegram;proxy;websocket;
EOF
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
Package: tg-ws-proxy
Version: ${VERSION}
Section: net
Priority: optional
Architecture: amd64
Maintainer: Flowseal
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
Description: Telegram Desktop WebSocket Bridge Proxy
SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI.
EOF
dpkg-deb --build --root-owner-group \
"$PKG_ROOT" \
"dist/TgWsProxy_linux_amd64.deb"
- name: Upload artifact
uses: actions/upload-artifact@v4
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
name: TgWsProxy-linux
path: |
dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb
path: out
merge-multiple: true
release:
needs: [build, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }}
steps:
- name: Download main build
uses: actions/download-artifact@v4
- name: Resolve latest target release
id: target-release
uses: actions/github-script@v8
with:
name: TgWsProxy
path: dist
script: |
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
- name: Download Win7 build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-win7
path: dist
const picked = releases.find((rel) => !rel.draft && !rel.prerelease);
if (!picked) {
core.setFailed('No release found to update');
return;
}
- name: Download macOS build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-macOS
path: dist
core.setOutput('id', String(picked.id));
core.setOutput('tag', picked.tag_name);
- name: Download Linux build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-linux
path: dist
- 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_windows.exe
dist/TgWsProxy_windows_7.exe
dist/TgWsProxy_macos_universal.dmg
dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb
draft: false
prerelease: false
- 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 }}"'"}}'

30
.gitignore vendored
View File

@ -1,30 +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/
/icon.icns

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"

225
README.md
View File

@ -1,197 +1,76 @@
> [!CAUTION]
>
> ### Реакция антивирусов
>
> Windows Defender часто ошибочно помечает приложение как **Wacatac**.
> Если вы не можете скачать из-за блокировки, то:
>
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
>
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
# TG WS Proxy Go (KeeneticOS)
# TG WS Proxy
**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
<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 → 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 через домены Telegram
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
### Config
## 🚀 Быстрый старт
Main config file:
### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
**Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
1. Открыть образ
2. Перенести **TG WS Proxy.app** в папку **Applications**
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
### Linux
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**.
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```shell
/opt/etc/tg-ws-proxy.conf
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
Minimal config example:
## Установка из исходников
### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
tg-ws-proxy
```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=""
```
### Windows 10+
> Notes:
```bash
pip install -e ".[win10]"
tg-ws-proxy-tray-win
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"
```
### Windows 7
### Run
```bash
pip install -e ".[win7]"
tg-ws-proxy-tray-win
```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
```
### macOS
### Logs
```bash
pip install -e ".[macos]"
tg-ws-proxy-tray-macos
If `LOG_LEVEL=1`, service logs are written to:
```shell
/opt/var/log/tg-ws-proxy.log
```
### Linux
### Remove
```bash
pip install -e ".[linux]"
tg-ws-proxy-tray-linux
```shell
opkg remove tg-ws-proxy
```
### Консольный режим из исходников
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Аргументы:**
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси |
| `--host` | `127.0.0.1` | Хост SOCKS5-прокси |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
```bash
# Стандартный запуск
tg-ws-proxy
# Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
tg-ws-proxy -v
```
## CLI-скрипты (pyproject.toml)
CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`.
Пример:
```toml
[project.scripts]
tg-ws-proxy = "proxy.tg_ws_proxy:main"
tg-ws-proxy-tray-win = "windows:main"
tg-ws-proxy-tray-macos = "macos:main"
tg-ws-proxy-tray-linux = "linux:main"
```
## Настройка Telegram Desktop
### Автоматически
ПКМ по иконке в трее → **«Открыть в Telegram»**
### Вручную
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
## Конфигурация
Tray-приложение хранит данные в:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"port": 1080,
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false
}
```
## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy_windows.exe`
- Windows 7 для `TgWsProxy_windows_7.exe`
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея)
## Лицензия
[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

871
linux.py
View File

@ -1,871 +0,0 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
import logging.handlers
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Dict, Optional
import customtkinter as ctk
import psutil
import pyperclip
import pystray
from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / 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,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
_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
try:
cmdline = proc.cmdline()
for arg in cmdline:
if "linux.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
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, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
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(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
size=int(size * 0.55),
)
except Exception:
try:
font = ImageFont.truetype(
"/usr/share/fonts/TTF/DejaVuSans-Bold.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 "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)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_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 — Ошибка"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showerror(title, text, parent=root)
root.destroy()
def _show_info(text: str, title: str = "TG WS Proxy"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showinfo(title, text, parent=root)
root.destroy()
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("Copying %s", url)
try:
pyperclip.copy(url)
_show_info(
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\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)
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, _photo)
TG_BLUE = "#3390ec"
TG_BLUE_HOVER = "#2b7cd4"
BG = "#ffffff"
FIELD_BG = "#f0f2f5"
FIELD_BORDER = "#d6d9dc"
TEXT_PRIMARY = "#000000"
TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Sans"
w, h = 420, 540
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=("Monospace", 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))
# Advanced: buf_kb, pool_size, log_max_mb
adv_frame = ctk.CTkFrame(frame, fg_color="transparent")
adv_frame.pack(anchor="w", fill="x", pady=(4, 8))
for col, (lbl, key, w_) in enumerate([
("Буфер (KB, 256 default)", "buf_kb", 120),
("WS пулов (4 default)", "pool_size", 120),
("Log size (MB, 5 def)", "log_max_mb", 120),
]):
col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent")
col_frame.pack(side="left", padx=(0, 10))
ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11),
text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w")
ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12),
corner_radius=8, fg_color=FIELD_BG,
border_color=FIELD_BORDER, border_width=1,
text_color=TEXT_PRIMARY,
textvariable=ctk.StringVar(
value=str(cfg.get(key, DEFAULT_CONFIG[key]))
)).pack(anchor="w")
_adv_entries = list(adv_frame.winfo_children())
_adv_keys = ["buf_kb", "pool_size", "log_max_mb"]
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(),
}
for i, key in enumerate(_adv_keys):
col_frame = _adv_entries[i]
entry = col_frame.winfo_children()[1]
try:
val = float(entry.get().strip())
if key in ("buf_kb", "pool_size"):
val = int(val)
new_cfg[key] = val
except ValueError:
new_cfg[key] = DEFAULT_CONFIG[key]
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", pady=(20, 0))
ctk.CTkButton(btn_frame, text="Сохранить", 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", fill="x", expand=True, padx=(0, 8))
ctk.CTkButton(btn_frame, text="Отмена", 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="right", fill="x", expand=True)
root.mainloop()
def _on_open_logs(icon=None, item=None):
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
subprocess.Popen(
["xdg-open", str(LOG_FILE)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
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 = "Sans"
root = ctk.CTk()
root.title("TG WS Proxy")
root.resizable(False, False)
root.attributes("-topmost", True)
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, _photo)
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_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
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()

663
macos.py
View File

@ -1,663 +0,0 @@
from __future__ import annotations
import json
import logging
import logging.handlers
import os
import psutil
import subprocess
import sys
import threading
import time
import webbrowser
import asyncio as _asyncio
from pathlib import Path
from typing import Dict, Optional
try:
import rumps
except ImportError:
rumps = None
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = ImageDraw = ImageFont = None
try:
import pyperclip
except ImportError:
pyperclip = None
import proxy.tg_ws_proxy as tg_ws_proxy
APP_NAME = "TgWsProxy"
APP_DIR = Path.home() / "Library" / "Application Support" / 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"
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_app: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray")
# Single-instance lock
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 APP_NAME.lower() in proc.name().lower()
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
# Filesystem helpers
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, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
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)
# Menubar icon
def _make_menubar_icon(size: int = 44):
if Image is None:
return None
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = size // 11
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 0, 0, 255))
try:
font = ImageFont.truetype(
"/System/Library/Fonts/Helvetica.ttc",
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
# Generate menubar icon PNG if it does not exist.
def _ensure_menubar_icon():
if MENUBAR_ICON_PATH.exists():
return
_ensure_dirs()
img = _make_menubar_icon(44)
if img:
img.save(str(MENUBAR_ICON_PATH), "PNG")
# Native macOS dialogs
def _osascript(script: str) -> str:
r = subprocess.run(
['osascript', '-e', script],
capture_output=True, text=True)
return r.stdout.strip()
def _show_error(text: str, title: str = "TG WS Proxy"):
text_esc = text.replace('\\', '\\\\').replace('"', '\\"')
title_esc = title.replace('\\', '\\\\').replace('"', '\\"')
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon stop')
def _show_info(text: str, title: str = "TG WS Proxy"):
text_esc = text.replace('\\', '\\\\').replace('"', '\\"')
title_esc = title.replace('\\', '\\\\').replace('"', '\\"')
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon note')
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
text_esc = text.replace('\\', '\\\\').replace('"', '\\"')
title_esc = title.replace('\\', '\\\\').replace('"', '\\"')
result = _osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"Нет", "Да"}} default button "Да" with icon note')
return "Да" in result
# Proxy lifecycle
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 "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)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_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()
# Menu callbacks
def _on_open_in_telegram(_=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 = subprocess.call(['open', url])
if result != 0:
raise RuntimeError("open command failed")
except Exception:
log.info("open command failed, trying webbrowser")
try:
if not webbrowser.open(url):
raise RuntimeError("webbrowser.open returned False")
except Exception:
log.info("Browser open failed, copying to clipboard")
try:
if pyperclip:
pyperclip.copy(url)
else:
subprocess.run(['pbcopy'], input=url.encode(),
check=True)
_show_info(
"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена:\n{url}")
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(_=None):
def _do_restart():
global _config
_config = load_config()
if _app:
_app.update_menu_title()
restart_proxy()
threading.Thread(target=_do_restart, daemon=True).start()
def _on_open_logs(_=None):
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
subprocess.call(['open', str(LOG_FILE)])
else:
_show_info("Файл логов ещё не создан.")
# Show a native text input dialog. Returns None if cancelled.
def _osascript_input(prompt: str, default: str,
title: str = "TG WS Proxy") -> Optional[str]:
prompt_esc = prompt.replace('\\', '\\\\').replace('"', '\\"')
default_esc = default.replace('\\', '\\\\').replace('"', '\\"')
title_esc = title.replace('\\', '\\\\').replace('"', '\\"')
r = subprocess.run(
['osascript', '-e',
f'text returned of (display dialog "{prompt_esc}" '
f'default answer "{default_esc}" '
f'with title "{title_esc}" '
f'buttons {{"Отмена", "OK"}} default button "OK")'],
capture_output=True, text=True)
if r.returncode != 0:
return None
return r.stdout.rstrip("\r\n")
def _on_edit_config(_=None):
threading.Thread(target=_edit_config_dialog, daemon=True).start()
# Settings via native macOS dialogs
def _edit_config_dialog():
cfg = load_config()
# Host
host = _osascript_input(
"IP-адрес прокси:",
cfg.get("host", DEFAULT_CONFIG["host"]))
if host is None:
return
host = host.strip()
import socket as _sock
try:
_sock.inet_aton(host)
except OSError:
_show_error("Некорректный IP-адрес.")
return
# Port
port_str = _osascript_input(
"Порт прокси:",
str(cfg.get("port", DEFAULT_CONFIG["port"])))
if port_str is None:
return
try:
port = int(port_str.strip())
if not (1 <= port <= 65535):
raise ValueError
except ValueError:
_show_error("Порт должен быть числом 1-65535")
return
# DC-IP mappings
dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))
dc_str = _osascript_input(
"DC → IP маппинги (через запятую, формат DC:IP):\n"
"Например: 2:149.154.167.220, 4:149.154.167.220",
dc_default)
if dc_str is None:
return
dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines()
if s.strip()]
try:
tg_ws_proxy.parse_dc_ip_list(dc_lines)
except ValueError as e:
_show_error(str(e))
return
# Verbose
verbose = _ask_yes_no("Включить подробное логирование (verbose)?")
# Advanced settings
adv_str = _osascript_input(
"Расширенные настройки (буфер KB, WS пул, лог MB):\n"
"Формат: buf_kb,pool_size,log_max_mb",
f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])},"
f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])},"
f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}")
adv = {}
if adv_str:
parts = [s.strip() for s in adv_str.split(',')]
keys = [("buf_kb", int), ("pool_size", int),
("log_max_mb", float)]
for i, (k, typ) in enumerate(keys):
if i < len(parts):
try:
adv[k] = typ(parts[i])
except ValueError:
pass
new_cfg = {
"host": host,
"port": port,
"dc_ip": dc_lines,
"verbose": verbose,
"buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])),
"pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])),
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
}
save_config(new_cfg)
log.info("Config saved: %s", new_cfg)
global _config
_config = new_cfg
if _app:
_app.update_menu_title()
if _ask_yes_no("Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
restart_proxy()
# First-run & IPv6 dialogs
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}"
text = (
f"Прокси запущен и работает в строке меню.\n\n"
f"Как подключить Telegram Desktop:\n\n"
f"Автоматически:\n"
f" Нажмите «Открыть в Telegram» в меню\n"
f" Или ссылка: {tg_url}\n\n"
f"Вручную:\n"
f" Настройки → Продвинутые → Тип подключения → Прокси\n"
f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n"
f"Открыть прокси в Telegram сейчас?"
)
FIRST_RUN_MARKER.touch()
if _ask_yes_no(text, "TG WS Proxy"):
_on_open_in_telegram()
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()
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает, попробуйте отключить "
"попытку соединения по IPv6 в настройках прокси Telegram.\n\n"
"Это предупреждение будет показано только один раз.")
# rumps menubar app
_TgWsProxyAppBase = rumps.App if rumps else object
class TgWsProxyApp(_TgWsProxyAppBase):
def __init__(self):
_ensure_menubar_icon()
icon_path = (str(MENUBAR_ICON_PATH)
if MENUBAR_ICON_PATH.exists() else None)
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
self._open_tg_item = rumps.MenuItem(
f"Открыть в Telegram ({host}:{port})",
callback=_on_open_in_telegram)
self._restart_item = rumps.MenuItem(
"Перезапустить прокси",
callback=_on_restart)
self._settings_item = rumps.MenuItem(
"Настройки...",
callback=_on_edit_config)
self._logs_item = rumps.MenuItem(
"Открыть логи",
callback=_on_open_logs)
super().__init__(
"TG WS Proxy",
icon=icon_path,
template=False,
quit_button="Выход",
menu=[
self._open_tg_item,
None,
self._restart_item,
self._settings_item,
self._logs_item,
])
def update_menu_title(self):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
self._open_tg_item.title = (
f"Открыть в Telegram ({host}:{port})")
def run_menubar():
global _app, _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_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
log.info("TG WS Proxy menubar app starting")
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
if rumps is None or Image is None:
log.error("rumps 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()
_app = TgWsProxyApp()
log.info("Menubar app running")
_app.run()
stop_proxy()
log.info("Menubar app exited")
def main():
if not _acquire_lock():
_show_info("Приложение уже запущено.")
return
try:
run_menubar()
finally:
_release_lock()
if __name__ == "__main__":
main()

View File

@ -1,80 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi')
# Collect GObject typelib files from the system
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0')
typelib_datas = []
for d in typelib_dirs:
typelib_datas.append((d, 'gi_typelibs'))
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas,
hiddenimports=[
'pystray._appindicator',
'PIL._tkinter_finder',
'customtkinter',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl',
'gi',
'_gi',
'gi.repository.GLib',
'gi.repository.GObject',
'gi.repository.Gtk',
'gi.repository.Gdk',
'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
cipher=block_cipher,
)
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=True,
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,
)

View File

@ -1,83 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
block_cipher = None
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
'rumps',
'objc',
'Foundation',
'AppKit',
'PyObjCTools',
'PyObjCTools.AppHelper',
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
cipher=block_cipher,
)
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
if not os.path.exists(icon_path):
icon_path = None
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='TgWsProxy',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
argv_emulation=False,
target_arch='universal2',
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name='TgWsProxy',
)
app = BUNDLE(
coll,
name='TG WS Proxy.app',
icon=icon_path,
bundle_identifier='com.tgwsproxy.app',
info_plist={
'CFBundleName': 'TG WS Proxy',
'CFBundleDisplayName': 'TG WS Proxy',
'CFBundleShortVersionString': '1.0.0',
'CFBundleVersion': '1.0.0',
'LSMinimumSystemVersion': '10.15',
'LSUIElement': True,
'NSHighResolutionCapable': True,
'NSAppleEventsUsageDescription':
'TG WS Proxy needs to display dialogs.',
},
)

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,
)

View File

@ -1 +0,0 @@
__version__ = "1.1.3"

File diff suppressed because it is too large Load Diff

View File

@ -1,95 +0,0 @@
[build-system]
requires = ["hatchling>=1.25.0"]
build-backend = "hatchling.build"
[project]
name = "tg-ws-proxy"
dynamic=["version"]
description = "Telegram Desktop WebSocket Bridge Proxy"
readme = "README.md"
requires-python = ">=3.8"
license = { name = "MIT", file = "LICENSE" }
authors = [
{ name = "Flowseal" }
]
keywords = [
"telegram",
"proxy",
"websocket"
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Environment :: MacOS X :: Cocoa",
"Environment :: Win32 (MS Windows)",
"Environment :: X11 Applications :: GTK",
"Intended Audience :: Customer Service",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Topic :: System :: Networking :: Firewalls",
]
dependencies = [
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
]
[project.optional-dependencies]
win7 = [
"customtkinter==5.2.2",
"Pillow==10.4.0",
"psutil==5.9.8",
"pystray==0.19.5",
"pyperclip==1.9.0",
]
win10 = [
"customtkinter==5.2.2",
"Pillow==12.1.1",
"psutil==7.0.0",
"pystray==0.19.5",
"pyperclip==1.9.0",
]
macos = [
"Pillow==12.1.0",
"psutil==7.0.0",
"pyperclip==1.9.0",
"rumps==0.4.0",
]
linux = [
"customtkinter==5.2.2",
"Pillow==12.1.1",
"psutil==7.0.0",
"pystray==0.19.5",
"pyperclip==1.9.0",
]
[project.scripts]
tg-ws-proxy = "proxy.tg_ws_proxy:main"
tg-ws-proxy-tray-win = "windows:main"
tg-ws-proxy-tray-macos = "macos:main"
tg-ws-proxy-tray-linux = "linux:main"
[project.urls]
Source = "https://github.com/Flowseal/tg-ws-proxy"
Issues = "https://github.com/Flowseal/tg-ws-proxy/issues"
[tool.hatch.build.targets.wheel]
packages = ["proxy"]
[tool.hatch.build.force-include]
"windows.py" = "windows.py"
"macos.py" = "macos.py"
"linux.py" = "linux.py"
[tool.hatch.version]
path = "proxy/__init__.py"

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,246 +0,0 @@
"""
Stress-test: сравнение OLD vs NEW реализаций горячих функций прокси.
Тестируются:
1. _build_frame — сборка WS-фрейма (masked binary)
2. _build_frame — сборка WS-фрейма (unmasked)
3. _socks5_reply — генерация SOCKS5-ответа
4. _dc_from_init XOR-часть (bytes(a^b for …) vs int.from_bytes)
5. mask key generation (os.urandom vs PRNG)
"""
import gc
import os
import random
import struct
import time
# ── Размеры данных, типичные для Telegram ──────────────────────────
SMALL = 64 # init-пакет / ack
MEDIUM = 1024 # текстовое сообщение
LARGE = 65536 # фото / голосовое
# ═══════════════════════════════════════════════════════════════════
# XOR mask (не менялся — для полноты)
# ═══════════════════════════════════════════════════════════════════
def xor_mask(data: bytes, mask: bytes) -> bytes:
if not data:
return data
n = len(data)
mask_rep = (mask * (n // 4 + 1))[:n]
return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
# ═══════════════════════════════════════════════════════════════════
# _build_frame
# ═══════════════════════════════════════════════════════════════════
def build_frame_old(opcode: int, data: bytes, mask: bool = False) -> bytes:
"""Старая: bytearray + append/extend + os.urandom."""
header = bytearray()
header.append(0x80 | 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
# ── Новая: pre-compiled struct + PRNG ──────────────────────────────
_st_BB = struct.Struct('>BB')
_st_BBH = struct.Struct('>BBH')
_st_BBQ = struct.Struct('>BBQ')
_st_BB4s = struct.Struct('>BB4s')
_st_BBH4s = struct.Struct('>BBH4s')
_st_BBQ4s = struct.Struct('>BBQ4s')
_mask_rng = random.Random(int.from_bytes(os.urandom(16), 'big'))
_mask_pack = struct.Struct('>I').pack
def _random_mask_key() -> bytes:
return _mask_pack(_mask_rng.getrandbits(32))
def build_frame_new(opcode: int, data: bytes, mask: bool = False) -> bytes:
"""Новая: struct.pack + PRNG mask."""
length = len(data)
fb = 0x80 | opcode
if not mask:
if length < 126:
return _st_BB.pack(fb, length) + data
if length < 65536:
return _st_BBH.pack(fb, 126, length) + data
return _st_BBQ.pack(fb, 127, length) + data
mask_key = _random_mask_key()
masked = xor_mask(data, mask_key)
if length < 126:
return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
if length < 65536:
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
# ═══════════════════════════════════════════════════════════════════
# _socks5_reply
# ═══════════════════════════════════════════════════════════════════
def socks5_reply_old(status):
return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6
_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
for s in (0x00, 0x05, 0x07, 0x08)}
def socks5_reply_new(status):
return _SOCKS5_REPLIES[status]
# ═══════════════════════════════════════════════════════════════════
# dc_from_init XOR (8 байт keystream ^ data)
# ═══════════════════════════════════════════════════════════════════
def dc_xor_old(data8: bytes, ks8: bytes) -> bytes:
"""Старая: генераторное выражение."""
return bytes(a ^ b for a, b in zip(data8, ks8))
def dc_xor_new(data8: bytes, ks8: bytes) -> bytes:
"""Новая: int.from_bytes."""
return (int.from_bytes(data8, 'big') ^ int.from_bytes(ks8, 'big')).to_bytes(8, 'big')
# ═══════════════════════════════════════════════════════════════════
# mask key: os.urandom(4) vs PRNG
# ═══════════════════════════════════════════════════════════════════
def mask_key_old() -> bytes:
return os.urandom(4)
def mask_key_new() -> bytes:
return _random_mask_key()
# ═══════════════════════════════════════════════════════════════════
# Бенчмарк
# ═══════════════════════════════════════════════════════════════════
def bench(func, args_list: list, iters: int) -> float:
gc.collect()
for i in range(min(100, iters)):
func(*args_list[i % len(args_list)])
start = time.perf_counter()
for i in range(iters):
func(*args_list[i % len(args_list)])
elapsed = time.perf_counter() - start
return elapsed / iters * 1_000_000 # мкс
def compare(name: str, old_fn, new_fn, args_list: list, iters: int):
t_old = bench(old_fn, args_list, iters)
t_new = bench(new_fn, args_list, iters)
speedup = t_old / t_new if t_new > 0 else float('inf')
marker = '' if speedup >= 1.0 else '⚠️'
print(f" {name:.<42s} OLD {t_old:8.3f} мкс | NEW {t_new:8.3f} мкс | {speedup:5.2f}x {marker}")
# ═══════════════════════════════════════════════════════════════════
def main():
print("=" * 74)
print(" Stress Test: OLD vs NEW (горячие функции tg_ws_proxy)")
print("=" * 74)
N = 500_000
# # ── 1. _build_frame masked ────────────────────────────────────
# print(f"\n── _build_frame masked ({N:,} итераций) ──")
# for size, label in [(SMALL, "64B"), (MEDIUM, "1KB"), (LARGE, "64KB")]:
# data_list = [(0x2, os.urandom(size), True) for _ in range(1000)]
# compare(f"build_frame masked {label}",
# build_frame_old, build_frame_new, data_list, N)
# # ── 2. _build_frame unmasked ──────────────────────────────────
# print(f"\n── _build_frame unmasked ({N:,} итераций) ──")
# for size, label in [(SMALL, "64B"), (MEDIUM, "1KB"), (LARGE, "64KB")]:
# data_list = [(0x2, os.urandom(size), False) for _ in range(1000)]
# compare(f"build_frame unmasked {label}",
# build_frame_old, build_frame_new, data_list, N)
# # ── 3. mask key generation ────────────────────────────────────
# print(f"\n── mask key: os.urandom(4) vs PRNG ({N:,} итераций) ──")
# compare("mask_key", mask_key_old, mask_key_new, [()] * 100, N)
# # ── 4. _socks5_reply ─────────────────────────────────────────
N2 = 2_000_000
# print(f"\n── _socks5_reply ({N2:,} итераций) ──")
# compare("socks5_reply", socks5_reply_old, socks5_reply_new,
# [(s,) for s in (0x00, 0x05, 0x07, 0x08)], N2)
# # ── 5. dc_from_init XOR (8 bytes) ────────────────────────────
# print(f"\n── dc_xor 8B: generator vs int.from_bytes ({N2:,} итераций) ──")
# compare("dc_xor_8B", dc_xor_old, dc_xor_new,
# [(os.urandom(8), os.urandom(8)) for _ in range(1000)], N2)
# ── 6. _read_frame struct.unpack vs pre-compiled ─────────────
print(f"\n── struct unpack read-path ({N2:,} итераций) ──")
_st_H_pre = struct.Struct('>H')
_st_Q_pre = struct.Struct('>Q')
h_bufs = [(os.urandom(2),) for _ in range(1000)]
q_bufs = [(os.urandom(8),) for _ in range(1000)]
compare("unpack >H",
lambda b: struct.unpack('>H', b),
lambda b: _st_H_pre.unpack(b),
h_bufs, N2)
compare("unpack >Q",
lambda b: struct.unpack('>Q', b),
lambda b: _st_Q_pre.unpack(b),
q_bufs, N2)
# ── 7. dc_from_init: 2x unpack vs 1x merged ─────────────────
print(f"\n── dc_from_init unpack: 2 calls vs 1 merged ({N2:,} итераций) ──")
_st_Ih = struct.Struct('<Ih')
plains = [(os.urandom(8),) for _ in range(1000)]
def dc_unpack_old(p):
return struct.unpack('<I', p[0:4])[0], struct.unpack('<h', p[4:6])[0]
def dc_unpack_new(p):
return _st_Ih.unpack(p[:6])
compare("dc_unpack", dc_unpack_old, dc_unpack_new, plains, N2)
# ── 8. bytes() copy vs direct slice ──────────────────────────
print(f"\n── bytes(slice) vs direct slice ({N2:,} итераций) ──")
raw_data = [(os.urandom(64),) for _ in range(1000)]
def slice_copy(d):
return bytes(d[8:40]), bytes(d[40:56])
def slice_direct(d):
return d[8:40], d[40:56]
compare("bytes(slice) vs slice", slice_copy, slice_direct, raw_data, N2)
# ── 9. MsgSplitter unpack_from: struct vs pre-compiled ───────
print(f"\n── unpack_from <I: struct vs pre-compiled ({N2:,} итераций) ──")
_st_I_le = struct.Struct('<I')
splitter_bufs = [(os.urandom(64), 1) for _ in range(1000)]
compare("unpack_from <I",
lambda b, p: struct.unpack_from('<I', b, p),
lambda b, p: _st_I_le.unpack_from(b, p),
splitter_bufs, N2)
print("\n" + "=" * 74)
print(" Готово!")
print("=" * 74)
if __name__ == "__main__":
main()

View File

@ -1,842 +0,0 @@
from __future__ import annotations
import ctypes
import json
import logging
import logging.handlers
import os
import winreg
import psutil
import sys
import threading
import time
import webbrowser
import pyperclip
import asyncio as _asyncio
from pathlib import Path
from typing import Dict, Optional
import pystray
import customtkinter as ctk
from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy
IS_FROZEN = bool(getattr(sys, "frozen", False))
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,
"autostart": False,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
_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, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
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 _autostart_reg_name() -> str:
return APP_NAME
def _supports_autostart() -> bool:
return IS_FROZEN
def _autostart_command() -> str:
return f'"{sys.executable}"'
def is_autostart_enabled() -> bool:
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0,
winreg.KEY_READ,
) as k:
val, _ = winreg.QueryValueEx(k, _autostart_reg_name())
stored = str(val).strip()
expected = _autostart_command().strip()
return stored == expected
except FileNotFoundError:
return False
except OSError:
return False
def set_autostart_enabled(enabled: bool) -> None:
try:
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
) as k:
if enabled:
winreg.SetValueEx(
k,
_autostart_reg_name(),
0,
winreg.REG_SZ,
_autostart_command(),
)
else:
try:
winreg.DeleteValue(k, _autostart_reg_name())
except FileNotFoundError:
pass
except OSError as exc:
log.error("Failed to update autostart: %s", exc)
_show_error(
"Не удалось изменить автозапуск.\n\n"
"Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n"
f"Ошибка: {exc}"
)
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)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_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"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\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)
cfg["autostart"] = is_autostart_enabled()
# Make sure that the autostart key is removed if autostart
# is disabled, even if the executable file is moved.
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
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)
icon_path = str(Path(__file__).parent / "icon.ico")
root.iconbitmap(icon_path)
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, 540
if _supports_autostart():
h += 70
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))
# Advanced: buf_kb, pool_size, log_max_mb
adv_frame = ctk.CTkFrame(frame, fg_color="transparent")
adv_frame.pack(anchor="w", fill="x", pady=(4, 8))
for col, (lbl, key, w_) in enumerate([
("Буфер (KB, 256 default)", "buf_kb", 120),
("WS пулов (4 default)", "pool_size", 120),
("Log size (MB, 5 def)", "log_max_mb", 120),
]):
col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent")
col_frame.pack(side="left", padx=(0, 10))
ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11),
text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w")
ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12),
corner_radius=8, fg_color=FIELD_BG,
border_color=FIELD_BORDER, border_width=1,
text_color=TEXT_PRIMARY,
textvariable=ctk.StringVar(
value=str(cfg.get(key, DEFAULT_CONFIG[key]))
)).pack(anchor="w")
_adv_entries = list(adv_frame.winfo_children())
_adv_keys = ["buf_kb", "pool_size", "log_max_mb"]
autostart_var = None
if _supports_autostart():
autostart_var = ctk.BooleanVar(value=cfg["autostart"])
ctk.CTkCheckBox(frame, text="Автозапуск при включении Windows",
variable=autostart_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))
ctk.CTkLabel(frame, text="При перемещении файла или открытии из другой папки\nавтозапуск будет сброшен",
font=(FONT_FAMILY, 13), text_color=TEXT_SECONDARY,
anchor="w", justify="left").pack(anchor="w", pady=(0, 8))
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(),
"autostart": (autostart_var.get() if autostart_var is not None else False),
}
for i, key in enumerate(_adv_keys):
col_frame = _adv_entries[i]
entry = col_frame.winfo_children()[1]
try:
val = float(entry.get().strip())
if key in ("buf_kb", "pool_size"):
val = int(val)
new_cfg[key] = val
except ValueError:
new_cfg[key] = DEFAULT_CONFIG[key]
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
if _supports_autostart():
set_autostart_enabled(bool(new_cfg.get("autostart", False)))
_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", pady=(20, 0))
ctk.CTkButton(btn_frame, text="Сохранить", 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", fill="x", expand=True, padx=(0, 8))
ctk.CTkButton(btn_frame, text="Отмена", 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="right", fill="x", expand=True)
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)
icon_path = str(Path(__file__).parent / "icon.ico")
root.iconbitmap(icon_path)
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_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
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()