42 Commits
v1.2.0 ... 0.9

Author SHA1 Message Date
83d27dcc97 Release 0.9 2026-06-25 10:40:51 +03:00
99b844089d Release 0.8.1 2026-06-22 00:10:06 +03:00
ffb2803485 Release 0.8 2026-06-16 19:40:11 +03:00
0a6eab401d 0.7.2 2026-05-11 18:37:22 +03:00
43766ecbe5 0.7.1 2026-04-26 21:23:18 +03:00
f2d330f6d9 0.7 2026-04-19 15:57:07 +03:00
80ceb0220f 0.6 2026-04-12 12:52:51 +03:00
db4926e416 0.5 2026-04-11 17:10:37 +03:00
19bf9af215 0.4 2026-04-08 11:18:48 +03:00
7b92e6c8a9 0.3 2026-04-04 18:04:06 +03:00
07c019c387 0.2 2026-04-04 01:22:59 +03:00
0f4518da45 Initial commit
Some checks failed
Build tg-ws-proxy / build (aarch64, arm64, , , aarch64-3.10) (push) Has been cancelled
Build tg-ws-proxy / build (armv7, arm, 7, , armv7-3.2) (push) Has been cancelled
Build tg-ws-proxy / build (mips, mips, , softfloat, mips-3.4) (push) Has been cancelled
Build tg-ws-proxy / build (mipsel, mipsle, , softfloat, mipsel-3.4) (push) Has been cancelled
Build tg-ws-proxy / build (x64, amd64, , , x64) (push) Has been cancelled
Build tg-ws-proxy / publish-latest-release (push) Has been cancelled
2026-04-03 01:49:19 +03:00
da4b521aba Изменение README для AUR (#485) 2026-03-30 09:55:44 +03:00
07facfe18c Version bump 2026-03-29 20:00:31 +03:00
7a886dff26 Update ctk_theme.py (#480) 2026-03-29 19:56:43 +03:00
17e37f9ca0 host detect in first-run window 2026-03-29 19:55:39 +03:00
968827445f copy link, mtproto new first run notify 2026-03-29 17:57:55 +03:00
be8d178e5c secret validation 2026-03-29 17:30:39 +03:00
46426c45b0 ctk refactoring 2026-03-29 15:21:56 +03:00
c4a044542c fixes 2026-03-29 15:21:45 +03:00
af74009b11 icon fix 2026-03-28 16:48:59 +03:00
6766db9812 mtproto recode 2026-03-28 15:45:08 +03:00
95f99be26b old socks removed 2026-03-28 13:07:51 +03:00
0d11062c92 fix: остановка прокси из трея, пул WS и проверка обновлений (#443) 2026-03-27 10:54:33 +03:00
b3a9bc6a8f icon size increase 2026-03-27 09:17:15 +03:00
c179c299bb tooltip fixes 2026-03-27 09:07:25 +03:00
bd4746004e Docker image for headless proxy (#289) 2026-03-27 09:05:56 +03:00
77a0b837d9 Общий UI трея в ui/, тултипы, исправление tg:// с реальным host, доработки windows.py (импорты, lock, IPv6, остановка прокси) (#417) 2026-03-27 08:54:36 +03:00
5d28a50740 Исправление зависания "Обновления..." на iOS/iPadOS (#415) 2026-03-27 07:43:49 +03:00
7a1e2f3f5b Miss update actions/download-artifact (#412) 2026-03-23 18:06:46 +03:00
c0183bf448 Fix warn Node24 actions update & Simplify build (#410) 2026-03-23 17:10:20 +03:00
f95b9b7da0 Update win7 build according to #292 2026-03-23 09:12:06 +03:00
f3d05f7efc chore: pyproject optimization (#292) 2026-03-23 09:09:30 +03:00
e3d4578eed Добавление способа установки через AUR для Arch дистрибутивов (#296) 2026-03-23 08:39:29 +03:00
e1004e5e73 Fix macOS settings dialog cancellation flow (#392) 2026-03-23 08:39:00 +03:00
4304c71f89 build: win7 32bit support (#298) 2026-03-23 08:38:35 +03:00
3cb1929dc8 removed test script 2026-03-23 04:30:38 +03:00
afb7c5f56d revert keepalive mechanism 2026-03-22 08:00:14 +03:00
18a1bced83 logrotate #366; configurable pool and buffer sizes 2026-03-22 02:54:03 +03:00
ed85e2a284 keepalive for stale mitigation 2026-03-21 09:26:34 +03:00
c1452c23da Optimizations 2026-03-20 22:57:15 +03:00
6a80ca85e3 Optimizations 2026-03-19 22:07:47 +03:00
100 changed files with 4720 additions and 4286 deletions

4
.config.example Normal file
View File

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

View File

@ -1,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,245 @@
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
prepare:
name: Prepare build matrix
runs-on: ubuntu-latest
outputs:
configs: ${{ steps.collect.outputs.configs }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- 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: Build config matrix
id: collect
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"
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
from pathlib import Path
- name: Install dependencies
run: |
set -euo pipefail
python3.12 -m pip install --upgrade pip setuptools wheel
python3.12 -m pip install delocate==0.13.0
configs = sorted(Path("config").glob("*/*.config"))
mkdir -p wheelhouse/arm64 wheelhouse/x86_64 wheelhouse/universal2
if not configs:
raise SystemExit("No build configs found")
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
matrix = []
for config in configs:
matrix.append({
"config": config.as_posix(),
"name": config.stem,
})
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')
print(f"configs={json.dumps(matrix, separators=(',', ':'))}")
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
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build app with PyInstaller
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: src/go.mod
cache: true
cache-dependency-path: src/go.sum
- name: Validate universal2 app bundle
- name: Run tests
run: cd src && go test ./...
build:
name: Build packages (${{ matrix.name }})
needs: [ prepare, test ]
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.prepare.outputs.configs) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: src/go.mod
cache: true
cache-dependency-path: src/go.sum
- name: Install dependencies
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)
sudo apt-get update
sudo apt-get install -y dos2unix
if [ "$found" -eq 0 ]; then
echo "No Mach-O files found in app bundle" >&2
wget https://github.com/upx/upx/releases/download/v5.1.1/upx-5.1.1-amd64_linux.tar.xz
tar -xf upx-5.1.1-amd64_linux.tar.xz
sudo mv upx-5.1.1-amd64_linux/upx /usr/local/bin/upx
sudo chmod +x /usr/local/bin/upx
- name: Install apk-tools
if: ${{ startsWith(matrix.config, 'config/openwrt/') }}
run: |
docker run --rm -v /usr/local/bin:/mnt alpine:edge sh -c "apk add --no-cache apk-tools-static && cp /sbin/apk.static /mnt/apk && chmod +x /mnt/apk"
- name: Build and package
env:
OPENWRT_APK_SECRET_KEY: ${{ secrets.OPENWRT_APK_SECRET_KEY }}
run: |
cp "${{ matrix.config }}" .config
if [[ "${{ matrix.config }}" == config/openwrt/* ]]; then
if [ -z "$OPENWRT_APK_SECRET_KEY" ]; then
echo "OpenWrt APK signing key is required: OPENWRT_APK_SECRET_KEY" >&2
exit 1
fi
KEY_FILE="$RUNNER_TEMP/openwrt-apk-private.pem"
printf '%s' "$OPENWRT_APK_SECRET_KEY" > "$KEY_FILE"
export BUILD_KEY_APK_SEC="$KEY_FILE"
fi
make package
- name: Collect package files
id: package_outputs
run: |
mapfile -t ipk_packages < <(find .build -maxdepth 1 -type f -name 'tg-ws-proxy_*.ipk' | sort)
if [ ${#ipk_packages[@]} -eq 0 ]; then
echo "No ipk package files found" >&2
exit 1
fi
if [ ${#ipk_packages[@]} -gt 1 ]; then
echo "Expected one ipk package file, found ${#ipk_packages[@]}" >&2
printf '%s\n' "${ipk_packages[@]}"
exit 1
fi
- name: Create DMG
run: |
set -euo pipefail
APP_NAME="TG WS Proxy"
DMG_TEMP="dist/dmg_temp"
package_ipk="${ipk_packages[0]}"
rm -rf "$DMG_TEMP"
mkdir -p "$DMG_TEMP"
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
ln -s /Applications "$DMG_TEMP/Applications"
if [[ "${{ matrix.config }}" == config/openwrt/* ]]; then
mapfile -t apk_packages < <(find .build -maxdepth 1 -type f -name 'tg-ws-proxy_*.apk' | sort)
if [ ${#apk_packages[@]} -eq 0 ]; then
echo "No apk package files found for OpenWrt build" >&2
exit 1
fi
if [ ${#apk_packages[@]} -gt 1 ]; then
echo "Expected one apk package file, found ${#apk_packages[@]}" >&2
printf '%s\n' "${apk_packages[@]}"
exit 1
fi
package_apk="${apk_packages[0]}"
fi
hdiutil create \
-volname "$APP_NAME" \
-srcfolder "$DMG_TEMP" \
-ov \
-format UDZO \
"dist/TgWsProxy_macos_universal.dmg"
echo "package_ipk=$package_ipk" >> "$GITHUB_OUTPUT"
package_ipk_name="$(basename "$package_ipk")"
echo "package_ipk_name=$package_ipk_name" >> "$GITHUB_OUTPUT"
if [ -n "${package_apk:-}" ]; then
echo "package_apk=$package_apk" >> "$GITHUB_OUTPUT"
package_apk_name="$(basename "$package_apk")"
echo "package_apk_name=$package_apk_name" >> "$GITHUB_OUTPUT"
fi
rm -rf "$DMG_TEMP"
- name: Upload artifact
uses: actions/upload-artifact@v4
- name: Upload IPK artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-macOS
path: dist/TgWsProxy_macos_universal.dmg
name: ${{ steps.package_outputs.outputs.package_ipk_name }}
path: ${{ steps.package_outputs.outputs.package_ipk }}
if-no-files-found: error
compression-level: 0
retention-days: 1
build-linux:
- name: Upload APK artifact
if: ${{ steps.package_outputs.outputs.package_apk != '' }}
uses: actions/upload-artifact@v7
with:
name: ${{ steps.package_outputs.outputs.package_apk_name }}
path: ${{ steps.package_outputs.outputs.package_apk }}
if-no-files-found: error
compression-level: 0
retention-days: 1
publish-latest-release:
runs-on: ubuntu-latest
needs: [ build ]
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 package 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|apk|pem)$")) | .id' \
| while read -r asset_id; do
[ -n "$asset_id" ] || continue
gh api -X DELETE "repos/${{ github.repository }}/releases/assets/${asset_id}"
done
- name: Prepare APK public key asset
env:
OPENWRT_APK_PUBLIC_KEY: ${{ secrets.OPENWRT_APK_PUBLIC_KEY }}
run: |
if [ -n "$OPENWRT_APK_PUBLIC_KEY" ]; then
printf '%s' "$OPENWRT_APK_PUBLIC_KEY" > out/tg-ws-proxy.pem
fi
- name: Upload new package 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' -o -name 'tg-ws-proxy_*.apk' -o -name '*.pem' \) | sort)
if [ ${#files[@]} -eq 0 ]; then
echo "No package files found"
exit 1
fi
printf '%s\n' "${files[@]}"
gh release upload "$TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- 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.

167
Makefile Normal file
View File

@ -0,0 +1,167 @@
-include .config
SHELL := /bin/bash
PKG_NAME := tg-ws-proxy
PKG_DESCRIPTION := Telegram MTProto WS bridge proxy (Go binary)
PKG_LICENSE := MIT
PKG_SECTION := net
PKG_MAINTAINER := tg-ws-proxy maintainers
PKG_VERSION := $(shell cat VERSION)
PKG_REVISION ?= 1
PLATFORM ?=
TARGET ?=
GOOS ?=
GOARCH ?=
GOARM ?=
GOMIPS ?=
GO386 ?=
CGO_ENABLED ?= 0
GO_PROXY_DIR ?= src
ifeq ($(PLATFORM),entware)
PKG_DEPENDS := ca-certificates
else ifeq ($(PLATFORM),openwrt)
PKG_DEPENDS := ca-certificates
else
$(error Unsupported PLATFORM='$(PLATFORM)'; expected entware or openwrt)
endif
BUILDS_DIR := ./.build
BUILD_DIR := $(BUILDS_DIR)/$(PLATFORM)_$(TARGET)
COMPILE_DIR := $(BUILD_DIR)/compile
ROOT_DIR := $(BUILD_DIR)/root
ROOT_APK_DIR := $(BUILD_DIR)/root_apk
CONTROL_DIR := $(BUILD_DIR)/control
APK_DIR := $(BUILD_DIR)/apk
APK_ARCH ?= $(TARGET)
BUILD_KEY_APK_SEC ?=
ifeq ($(PLATFORM),entware)
BIN_DIR := $(ROOT_DIR)/opt/bin
ETC_DIR := $(ROOT_DIR)/opt/etc
VAR_DIR := $(ROOT_DIR)/opt/var
else
BIN_DIR := $(ROOT_DIR)/usr/bin
ETC_DIR := $(ROOT_DIR)/etc
VAR_DIR := $(ROOT_DIR)/var
endif
define _copy_files
if [ -d $(1)/_ipk/control ]; then mkdir -p "$(CONTROL_DIR)"; cp -r $(1)/_ipk/control/* "$(CONTROL_DIR)"; fi
if [ -d $(1)/_apk ]; then mkdir -p "$(APK_DIR)"; cp -r $(1)/_apk/* "$(APK_DIR)"; fi
if [ -d $(1)/bin ]; then mkdir -p "$(BIN_DIR)"; cp -r $(1)/bin/* "$(BIN_DIR)"; fi
if [ -d $(1)/etc ]; then mkdir -p "$(ETC_DIR)"; cp -r $(1)/etc/* "$(ETC_DIR)"; fi
if [ -d $(1)/var ]; then mkdir -p "$(VAR_DIR)"; cp -r $(1)/var/* "$(VAR_DIR)"; fi
endef
PACKAGE_FILE := $(BUILDS_DIR)/$(PKG_NAME)_$(PKG_VERSION)-$(PKG_REVISION)_$(PLATFORM)_$(TARGET).ipk
APK_PACKAGE_FILE := $(BUILDS_DIR)/$(PKG_NAME)_$(PKG_VERSION)-r$(PKG_REVISION)_$(PLATFORM)_$(TARGET).apk
.PHONY: all clean build prepare_files package package_ipk package_apk
all: build package
clean:
rm -rf $(BUILDS_DIR)
build:
mkdir -p "$(COMPILE_DIR)"
cd "$(GO_PROXY_DIR)" && \
GOOS="$(GOOS)" GOARCH="$(GOARCH)" GOARM="$(GOARM)" GOMIPS="$(GOMIPS)" GO386="$(GO386)" CGO_ENABLED="$(CGO_ENABLED)" \
go build -trimpath -ldflags="-w -s" -o "$(abspath $(COMPILE_DIR))/tg-ws-proxy" .
ifneq ($(filter $(GOARCH),riscv64 mips64 mips64le loong64),$(GOARCH))
upx -9 --lzma "$(COMPILE_DIR)/tg-ws-proxy"
endif
prepare_files: build
rm -rf "$(ROOT_DIR)" "$(CONTROL_DIR)"
mkdir -p "$(BIN_DIR)" "$(CONTROL_DIR)"
cp "$(COMPILE_DIR)/tg-ws-proxy" "$(BIN_DIR)/tg-ws-proxy"
$(call _copy_files,./files/common)
$(if $(filter entware,$(PLATFORM)), $(call _copy_files,./files/entware))
$(if $(filter openwrt,$(PLATFORM)), $(call _copy_files,./files/openwrt))
if [ -d "$(CONTROL_DIR)" ]; then find "$(CONTROL_DIR)" -type f -exec dos2unix {} +; fi
if [ -d "$(ETC_DIR)" ]; then find "$(ETC_DIR)" -type f -exec dos2unix {} +; fi
if [ -d "$(ETC_DIR)/init.d" ]; then find "$(ETC_DIR)/init.d" -type f -exec dos2unix {} +; fi
echo "Package: $(PKG_NAME)" > "$(CONTROL_DIR)/control"
echo "Version: $(PKG_VERSION)-$(PKG_REVISION)" >> "$(CONTROL_DIR)/control"
echo "Depends: $(PKG_DEPENDS)" >> "$(CONTROL_DIR)/control"
echo "Section: $(PKG_SECTION)" >> "$(CONTROL_DIR)/control"
echo "Architecture: $(TARGET)" >> "$(CONTROL_DIR)/control"
echo "License: $(PKG_LICENSE)" >> "$(CONTROL_DIR)/control"
echo "Maintainer: $(PKG_MAINTAINER)" >> "$(CONTROL_DIR)/control"
echo "Description: $(PKG_DESCRIPTION)" >> "$(CONTROL_DIR)/control"
chmod +x "$(BIN_DIR)/tg-ws-proxy"
if [ -d "$(ETC_DIR)/init.d" ]; then chmod +x "$(ETC_DIR)/init.d"/*; fi
if [ -f "$(CONTROL_DIR)/prerm" ]; then chmod +x "$(CONTROL_DIR)/prerm"; fi
if [ -f "$(CONTROL_DIR)/postinst" ]; then chmod +x "$(CONTROL_DIR)/postinst"; fi
if [ -f "$(CONTROL_DIR)/postrm" ]; then chmod +x "$(CONTROL_DIR)/postrm"; fi
package: package_ipk
ifeq ($(PLATFORM),openwrt)
package: package_apk
endif
package_ipk: prepare_files
mkdir -p "$(BUILDS_DIR)"
echo 2.0 > "$(BUILD_DIR)/debian-binary"
tar -C "$(CONTROL_DIR)" -czf "$(BUILD_DIR)/control.tar.gz" --owner=0 --group=0 .
tar -C "$(ROOT_DIR)" -czf "$(BUILD_DIR)/data.tar.gz" --owner=0 --group=0 .
tar -C "$(BUILD_DIR)" -czf "$(PACKAGE_FILE)" --owner=0 --group=0 debian-binary control.tar.gz data.tar.gz
@echo "Built: $(PACKAGE_FILE)"
package_apk: prepare_files
rm -rf "$(ROOT_APK_DIR)"
mkdir -p "$(ROOT_APK_DIR)"
cp -r "$(ROOT_DIR)/." "$(ROOT_APK_DIR)/"
mkdir -p "$(ROOT_APK_DIR)/lib/apk/packages"
if [ -f "$(APK_DIR)/conffiles" ]; then \
cp "$(APK_DIR)/conffiles" "$(ROOT_APK_DIR)/lib/apk/packages/$(PKG_NAME).conffiles"; \
for file in $$(cat "$(ROOT_APK_DIR)/lib/apk/packages/$(PKG_NAME).conffiles"); do \
[ -f "$(ROOT_APK_DIR)/$$file" ] || continue; \
csum=$$(sha256sum "$(ROOT_APK_DIR)/$$file" | cut -d' ' -f1); \
echo "$$file $$csum" >> "$(ROOT_APK_DIR)/lib/apk/packages/$(PKG_NAME).conffiles_static"; \
done; \
fi
(cd "$(ROOT_APK_DIR)" && find . -type f,l -printf "/%P\\n") > "$(ROOT_APK_DIR)/lib/apk/packages/$(PKG_NAME).list"
APK_SIGN_ARG=""; \
APK_SCRIPT_ARGS=""; \
if [ -n "$(BUILD_KEY_APK_SEC)" ] && [ -f "$(BUILD_KEY_APK_SEC)" ]; then \
APK_SIGN_ARG="--sign $(BUILD_KEY_APK_SEC)"; \
fi; \
if [ -f "$(APK_DIR)/post-install.sh" ]; then \
APK_SCRIPT_ARGS="$$APK_SCRIPT_ARGS -s post-install:$(APK_DIR)/post-install.sh"; \
fi; \
if [ -f "$(APK_DIR)/pre-deinstall.sh" ]; then \
APK_SCRIPT_ARGS="$$APK_SCRIPT_ARGS -s pre-deinstall:$(APK_DIR)/pre-deinstall.sh"; \
fi; \
if [ -f "$(APK_DIR)/post-upgrade.sh" ]; then \
APK_SCRIPT_ARGS="$$APK_SCRIPT_ARGS -s post-upgrade:$(APK_DIR)/post-upgrade.sh"; \
fi; \
apk mkpkg \
-I "name:$(PKG_NAME)" \
-I "version:$(PKG_VERSION)-r$(PKG_REVISION)" \
-I "description:$(PKG_DESCRIPTION)" \
-I "arch:$(APK_ARCH)" \
-I "license:$(PKG_LICENSE)" \
-I "origin:feeds/packages/feeds/tg-ws-proxy/net/$(PKG_NAME)" \
-I "maintainer:$(PKG_MAINTAINER)" \
-I "provider-priority:100" \
-F "$(ROOT_APK_DIR)" \
-o "$(APK_PACKAGE_FILE)" \
$$APK_SCRIPT_ARGS \
$$APK_SIGN_ARG
@echo "Built: $(APK_PACKAGE_FILE)"

256
README.md
View File

@ -1,197 +1,129 @@
> [!CAUTION]
>
> ### Реакция антивирусов
>
> Windows Defender часто ошибочно помечает приложение как **Wacatac**.
> Если вы не можете скачать из-за блокировки, то:
>
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
>
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
# TG WS Proxy Go for embedded devices ([FAQ](https://github.com/Flowseal/tg-ws-proxy/issues/389))
# 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
> KeeneticOS
Repository:
```shell
curl -fsSL https://raw.githubusercontent.com/spatiumstas/feedly/main/add-repo.sh | sh
```
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → 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-соединение
> OpenWRT (IPK, APK)
Insert package link from Releases
## 🚀 Быстрый старт
### 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
opkg install %link%
```
APK
```shell
wget -O "/etc/apk/keys/tg-ws-proxy.pem" "https://github.com/spatiumstas/tg-ws-proxy-go/releases/download/0.4/tg-ws-proxy.pem"
apk add %link%
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
### Config
## Установка из исходников
Main config file:
### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
tg-ws-proxy
```shell
# Entware (KeeneticOS):
# /opt/etc/tg-ws-proxy/config.conf
# /opt/etc/tg-ws-proxy/secret.conf
# OpenWrt/generic opkg:
# /etc/tg-ws-proxy/config.conf
# /etc/tg-ws-proxy/secret.conf
```
### Windows 10+
Minimal config example:
```bash
pip install -e ".[win10]"
tg-ws-proxy-tray-win
```conf
# config.conf
HOST=0.0.0.0
PORT=1443
LOG_LEVEL=0
DC_IP_DEFAULT=149.154.167.220
DC_IP_DEFAULT_POOL=""
FAKE_TLS_DOMAIN=""
CFPROXY_DOMAINS=""
CFPROXY_DOMAINS_URL="https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt"
EXTRA_ARGS=""
# secret.conf
SECRET=
```
### Windows 7
> Notes:
```bash
pip install -e ".[win7]"
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, [CFProxy](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md)
4. Full list of available commands `--help`
5. `FAKE_TLS_DOMAIN` enables Fake TLS mode (`ee` secret link). Keep empty for standard `dd` mode.
6. `CFPROXY_DOMAINS` - local fallback domain list.
7. `CFPROXY_DOMAINS_URL` [default value](https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt)
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"
# Fake TLS mode (ee-secret)
FAKE_TLS_DOMAIN="example.com"
```
### macOS
### Run
```bash
pip install -e ".[macos]"
tg-ws-proxy-tray-macos
```shell
# Entware (KeeneticOS)
/opt/etc/init.d/S99tg-ws-proxy start
/opt/etc/init.d/S99tg-ws-proxy status
/opt/etc/init.d/S99tg-ws-proxy restart
/opt/etc/init.d/S99tg-ws-proxy stop
# OpenWrt/generic OPKG
service tg-ws-proxy start
service tg-ws-proxy status
service tg-ws-proxy restart
service tg-ws-proxy stop
```
### Linux
### Logs
```bash
pip install -e ".[linux]"
tg-ws-proxy-tray-linux
If `LOG_LEVEL=1`, service logs are written to:
```shell
# Entware (KeeneticOS): /opt/var/log/tg-ws-proxy.log
# OpenWrt/generic OPKG: /var/log/tg-ws-proxy.log
```
### Консольный режим из исходников
### Build from profile
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```shell
cp config/entware/aarch64-3.10.config .config
make package
```
**Аргументы:**
Output package:
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--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
```shell
.build/tg-ws-proxy_<version>-1_<platform>_<target>.ipk
```
## CLI-скрипты (pyproject.toml)
### Remove
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"
```shell
opkg remove tg-ws-proxy
```
## Настройка Telegram Desktop
### Remove repository
### Автоматически
ПКМ по иконке в трее → **«Открыть в 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
}
```shell
rm /opt/etc/opkg/feedly.conf
```
## Автоматическая сборка
Проект содержит спецификации 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)

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.9

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
HOST=0.0.0.0
PORT=1443
LOG_LEVEL=0
DC_IP_DEFAULT=149.154.167.220
DC_IP_DEFAULT_POOL=""
FAKE_TLS_DOMAIN=""
CFPROXY_DOMAINS=""
CFPROXY_DOMAINS_URL="https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt"
CFPROXY_WORKER_DOMAINS=""
EXTRA_ARGS=""

View File

@ -0,0 +1 @@
SECRET=

View File

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

View File

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

View File

@ -0,0 +1,11 @@
#!/bin/sh
set -e
[ "${PKG_UPGRADE}" = "1" ] && exit 0
rm -f /opt/bin/tg-ws-proxy
rm -f /opt/etc/init.d/S99tg-ws-proxy
rm -rf /opt/etc/tg-ws-proxy
rm -f /opt/var/log/tg-ws-proxy.log
echo "tg-ws-proxy removed"

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="tg-ws-proxy"
add_group_and_user
CONFIG_DIR="${IPKG_INSTROOT}/etc/tg-ws-proxy"
CONFIG_FILE="$CONFIG_DIR/config.conf"
SECRET_FILE="$CONFIG_DIR/secret.conf"
mkdir -p "$CONFIG_DIR"
[ -f "$CONFIG_FILE" ] || : > "$CONFIG_FILE"
[ -f "$SECRET_FILE" ] || printf 'SECRET=\n' > "$SECRET_FILE"
. "$SECRET_FILE" || true
if [ -z "${SECRET:-}" ]; then
secret="$(${IPKG_INSTROOT}/usr/bin/tg-ws-proxy --gen-secret 2>/dev/null | tr -d ' \r\n' || true)"
if [ "${#secret}" -eq 32 ]; then
if grep -Eq '^[[:space:]]*SECRET=' "$SECRET_FILE"; then
sed -i "s|^[[:space:]]*SECRET=.*$|SECRET=$secret|" "$SECRET_FILE"
else
printf 'SECRET=%s\n' "$secret" >> "$SECRET_FILE"
fi
echo "Generated SECRET in $SECRET_FILE"
else
echo "WARNING: failed to generate SECRET automatically" >&2
fi
fi
default_postinst

View File

@ -0,0 +1,9 @@
#!/bin/sh
export PKG_UPGRADE=1
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="tg-ws-proxy"
add_group_and_user
default_postinst

View File

@ -0,0 +1,6 @@
#!/bin/sh
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="tg-ws-proxy"
default_prerm

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
#!/bin/sh
/etc/init.d/tg-ws-proxy stop 2>/dev/null || true
/etc/init.d/tg-ws-proxy disable 2>/dev/null || true
exit 0

View File

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

View File

@ -0,0 +1,121 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=99
NAME="tg-ws-proxy"
PROG="/usr/bin/tg-ws-proxy"
CONFIG_DIR="/etc/tg-ws-proxy"
CONFIG_FILE="$CONFIG_DIR/config.conf"
SECRET_FILE="$CONFIG_DIR/secret.conf"
LOGFILE="/var/log/tg-ws-proxy.log"
load_config() {
[ -f "$CONFIG_FILE" ] || {
echo "Config file not found: $CONFIG_FILE"
return 1
}
[ -f "$SECRET_FILE" ] || {
echo "Secret file not found: $SECRET_FILE"
return 1
}
. "$CONFIG_FILE"
. "$SECRET_FILE"
[ -n "$SECRET" ] || {
echo "SECRET is empty in $SECRET_FILE"
return 1
}
[ -n "${DC_IP_DEFAULT_POOL+x}" ] || DC_IP_DEFAULT_POOL=""
[ -n "${CFPROXY_DOMAINS+x}" ] || CFPROXY_DOMAINS=""
[ -n "${CFPROXY_DOMAINS_URL+x}" ] || CFPROXY_DOMAINS_URL=""
[ -n "${CFPROXY_WORKER_DOMAINS+x}" ] || CFPROXY_WORKER_DOMAINS=""
[ -n "${FAKE_TLS_DOMAIN+x}" ] || FAKE_TLS_DOMAIN=""
return 0
}
print_link() {
local link_host br_ip fk link
load_config || return 1
[ -n "$HOST" ] && [ -n "$PORT" ] && [ -n "$SECRET" ] || return 1
link_host="$HOST"
if [ "$link_host" = "0.0.0.0" ]; then
br_ip="$(ip -f inet addr show dev br-lan 2>/dev/null | sed -n 's/.*inet \([0-9.]\+\)\/.*/\1/p' | head -n 1)"
[ -n "$br_ip" ] || br_ip="$(ip -f inet addr show dev br0 2>/dev/null | sed -n 's/.*inet \([0-9.]\+\)\/.*/\1/p' | head -n 1)"
[ -n "$br_ip" ] && link_host="$br_ip"
fi
fk=""
[ -n "$FAKE_TLS_DOMAIN" ] && fk="--fake-tls-domain $FAKE_TLS_DOMAIN"
link="$("$PROG" --print-link --host "$link_host" --port "$PORT" --secret "$SECRET" $fk)"
echo "Connect link: $link"
}
start_service() {
config_load "$NAME"
local enabled user
config_get_bool enabled "main" "enabled" "0"
[ "$enabled" -eq "1" ] || return 0
load_config || return 1
config_get user "main" "user" "root"
procd_open_instance "$NAME.main"
procd_set_param command "$PROG" \
--host "$HOST" \
--port "$PORT" \
--secret "$SECRET" \
--dc-ip-default "$DC_IP_DEFAULT" \
--dc-ip-default-pool "$DC_IP_DEFAULT_POOL"
if [ -n "$CFPROXY_DOMAINS" ]; then
procd_append_param command --cfproxy-domains "$CFPROXY_DOMAINS"
fi
if [ -n "$CFPROXY_DOMAINS_URL" ]; then
procd_append_param command --cfproxy-domains-url "$CFPROXY_DOMAINS_URL"
fi
if [ -n "$CFPROXY_WORKER_DOMAINS" ]; then
procd_append_param command --cfproxy-worker-domain "$CFPROXY_WORKER_DOMAINS"
fi
if [ -n "$FAKE_TLS_DOMAIN" ]; then
procd_append_param command --fake-tls-domain "$FAKE_TLS_DOMAIN"
fi
if [ -n "$EXTRA_ARGS" ]; then
set -- $EXTRA_ARGS
while [ "$#" -gt 0 ]; do
procd_append_param command "$1"
shift
done
fi
if [ "$LOG_LEVEL" = "1" ]; then
procd_append_param command --log-file "$LOGFILE"
fi
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param user "$user"
procd_set_param respawn
procd_close_instance
print_link
return 0
}
status_service() {
if [ -n "$(pidof tg-ws-proxy 2>/dev/null)" ]; then
echo "tg-ws-proxy is running"
print_link
return 0
fi
echo "tg-ws-proxy is not running"
return 1
}

View File

@ -0,0 +1,2 @@
/etc/config/tg-ws-proxy
/etc/tg-ws-proxy/

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

843
linux.py
View File

@ -1,843 +0,0 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
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,
}
_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):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S"
)
)
root.addHandler(ch)
def _make_icon_image(size: int = 64):
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse(
[margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255)
)
try:
font = ImageFont.truetype(
"/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)
_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, 480
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
root.configure(fg_color=BG)
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=24, pady=20)
# Host
ctk.CTkLabel(
frame,
text="IP-адрес прокси",
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
anchor="w",
).pack(anchor="w", pady=(0, 4))
host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1"))
host_entry = ctk.CTkEntry(
frame,
textvariable=host_var,
width=200,
height=36,
font=(FONT_FAMILY, 13),
corner_radius=10,
fg_color=FIELD_BG,
border_color=FIELD_BORDER,
border_width=1,
text_color=TEXT_PRIMARY,
)
host_entry.pack(anchor="w", pady=(0, 12))
# Port
ctk.CTkLabel(
frame,
text="Порт прокси",
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
anchor="w",
).pack(anchor="w", pady=(0, 4))
port_var = ctk.StringVar(value=str(cfg.get("port", 1080)))
port_entry = ctk.CTkEntry(
frame,
textvariable=port_var,
width=120,
height=36,
font=(FONT_FAMILY, 13),
corner_radius=10,
fg_color=FIELD_BG,
border_color=FIELD_BORDER,
border_width=1,
text_color=TEXT_PRIMARY,
)
port_entry.pack(anchor="w", pady=(0, 12))
# DC-IP mappings
ctk.CTkLabel(
frame,
text="DC → IP маппинги (по одному на строку, формат DC:IP)",
font=(FONT_FAMILY, 13),
text_color=TEXT_PRIMARY,
anchor="w",
).pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
frame,
width=370,
height=120,
font=("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))
# Info label
ctk.CTkLabel(
frame,
text="Изменения вступят в силу после перезапуска прокси.",
font=(FONT_FAMILY, 11),
text_color=TEXT_SECONDARY,
anchor="w",
).pack(anchor="w", pady=(0, 16))
def on_save():
import socket as _sock
host_val = host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
_show_error("Некорректный IP-адрес.")
return
try:
port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
_show_error("Порт должен быть числом 1-65535")
return
lines = [
l.strip()
for l in dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()
]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
except ValueError as e:
_show_error(str(e))
return
new_cfg = {
"host": host_val,
"port": port_val,
"dc_ip": lines,
"verbose": verbose_var.get(),
}
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox
if messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x")
ctk.CTkButton(
btn_frame,
text="Сохранить",
width=140,
height=38,
font=(FONT_FAMILY, 14, "bold"),
corner_radius=10,
fg_color=TG_BLUE,
hover_color=TG_BLUE_HOVER,
text_color="#ffffff",
command=on_save,
).pack(side="left", padx=(0, 10))
ctk.CTkButton(
btn_frame,
text="Отмена",
width=140,
height=38,
font=(FONT_FAMILY, 14),
corner_radius=10,
fg_color=FIELD_BG,
hover_color=FIELD_BORDER,
text_color=TEXT_PRIMARY,
border_width=1,
border_color=FIELD_BORDER,
command=on_cancel,
).pack(side="left")
root.mainloop()
def _on_open_logs(icon=None, item=None):
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
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.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()

623
macos.py
View File

@ -1,623 +0,0 @@
from __future__ import annotations
import json
import logging
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,
}
_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):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S"))
root.addHandler(ch)
# 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)
_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)?")
new_cfg = {
"host": host,
"port": port,
"dc_ip": dc_lines,
"verbose": verbose,
}
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.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"

106
src/bridge_test.go Normal file
View File

@ -0,0 +1,106 @@
package main
import (
"bytes"
"crypto/rand"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
const (
testIOTimeout = 2 * time.Second
testDeliverGrace = 300 * time.Millisecond
)
func TestBridgeWSByteIntegrity(t *testing.T) {
secret := make([]byte, 16)
clientDecI := make([]byte, prekeyLen+ivLen)
relayInit := make([]byte, handshakeLen)
_, _ = rand.Read(secret)
_, _ = rand.Read(clientDecI)
_, _ = rand.Read(relayInit)
peerCltDec, peerCltEnc, peerTgEnc, peerTgDec, err := buildCiphers(clientDecI, relayInit, secret)
if err != nil {
t.Fatal(err)
}
upPlain := []byte("upstream-payload-from-client-app")
downPlain := []byte("downstream-payload-from-telegram")
upCh := make(chan []byte, 1)
upgrader := websocket.Upgrader{Subprotocols: []string{"binary"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
// Receive upstream (proxy re-encrypted for Telegram) and decrypt.
_, c2, err := c.ReadMessage()
if err != nil {
return
}
up := make([]byte, len(c2))
peerTgEnc.XORKeyStream(up, c2)
upCh <- up
// Send downstream encrypted so the proxy's tgDec recovers it.
c3 := make([]byte, len(downPlain))
peerTgDec.XORKeyStream(c3, downPlain)
_ = c.WriteMessage(websocket.BinaryMessage, c3)
time.Sleep(testDeliverGrace)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/apiws"
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
clientProxy, clientApp := net.Pipe()
cltDec, cltEnc, tgEnc, tgDec, err := buildCiphers(clientDecI, relayInit, secret)
if err != nil {
t.Fatal(err)
}
go bridgeWS("test", 2, false, clientProxy, ws, cltDec, cltEnc, tgEnc, tgDec, nil)
// Client app sends upstream (encrypted with its send stream == proxy cltDec).
c := make([]byte, len(upPlain))
peerCltDec.XORKeyStream(c, upPlain)
go func() {
_ = clientApp.SetWriteDeadline(time.Now().Add(testIOTimeout))
_, _ = clientApp.Write(c)
}()
select {
case got := <-upCh:
if !bytes.Equal(got, upPlain) {
t.Fatalf("upstream mismatch: got %q want %q", got, upPlain)
}
case <-time.After(testIOTimeout):
t.Fatal("timeout waiting for upstream bytes at Telegram side")
}
// Read downstream at the client app and decrypt with its recv stream (== cltEnc).
_ = clientApp.SetReadDeadline(time.Now().Add(testIOTimeout))
c4 := make([]byte, len(downPlain))
if _, err := io.ReadFull(clientApp, c4); err != nil {
t.Fatalf("reading downstream: %v", err)
}
dp := make([]byte, len(c4))
peerCltEnc.XORKeyStream(dp, c4)
if !bytes.Equal(dp, downPlain) {
t.Fatalf("downstream mismatch: got %q want %q", dp, downPlain)
}
_ = clientApp.Close()
_ = ws.Close()
}

369
src/cfproxy_domains.go Normal file
View File

@ -0,0 +1,369 @@
package main
import (
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var (
cfRandMu sync.Mutex
cfRand = rand.New(rand.NewSource(time.Now().UnixNano()))
)
func defaultCFProxyDomains() []string {
out := make([]string, 0, len(cfProxyDefaultDomainPool))
for _, domain := range cfProxyDefaultDomainPool {
decoded := decodeCFProxyDomain(domain)
if normalized := normalizeCFProxyDomain(decoded); normalized != "" {
out = appendUniqueDomains(out, normalized)
}
}
return out
}
func parseCFProxyDomainCSV(raw string) ([]string, error) {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
domain := normalizeCFProxyDomain(part)
if domain == "" {
continue
}
if !isLikelyDomain(domain) {
return nil, fmt.Errorf("invalid domain: %s", domain)
}
out = appendUniqueDomains(out, domain)
}
if len(out) == 0 {
return nil, fmt.Errorf("empty domain pool")
}
return out, nil
}
func normalizeCFProxyDomain(raw string) string {
return strings.ToLower(strings.Trim(strings.TrimSpace(raw), "."))
}
func appendUniqueDomains(dst []string, domains ...string) []string {
for _, domain := range domains {
normalized := normalizeCFProxyDomain(domain)
if normalized == "" {
continue
}
duplicate := false
for _, existing := range dst {
if existing == normalized {
duplicate = true
break
}
}
if !duplicate {
dst = append(dst, normalized)
}
}
return dst
}
func chooseActiveDomain(domains []string) string {
if len(domains) == 0 {
return ""
}
cfRandMu.Lock()
defer cfRandMu.Unlock()
return domains[cfRand.Intn(len(domains))]
}
func shuffledDomains(domains []string) []string {
out := append([]string(nil), domains...)
if len(out) <= 1 {
return out
}
cfRandMu.Lock()
cfRand.Shuffle(len(out), func(i, j int) {
out[i], out[j] = out[j], out[i]
})
cfRandMu.Unlock()
return out
}
func isLikelyDomain(domain string) bool {
if len(domain) < 3 || !strings.Contains(domain, ".") {
return false
}
for _, ch := range domain {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' {
continue
}
return false
}
return true
}
func isValidCFProxyDomain(domain string) bool {
d := normalizeCFProxyDomain(domain)
if d == "" || len(d) > 253 || strings.HasPrefix(d, ".") || strings.HasSuffix(d, ".") {
return false
}
labels := strings.Split(d, ".")
if len(labels) < 2 {
return false
}
for _, label := range labels {
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
for _, ch := range label {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' {
continue
}
return false
}
}
tld := labels[len(labels)-1]
if len(tld) < 2 {
return false
}
hasLetter := false
for _, ch := range tld {
if ch >= 'a' && ch <= 'z' {
hasLetter = true
break
}
}
return hasLetter
}
func decodeCFProxyDomain(raw string) string {
s := normalizeCFProxyDomain(raw)
if !strings.HasSuffix(s, ".com") {
return s
}
base := strings.TrimSuffix(s, ".com")
letters := 0
for _, ch := range base {
if ch >= 'a' && ch <= 'z' {
letters++
}
}
decoded := make([]byte, 0, len(base)+len(".co.uk"))
for i := 0; i < len(base); i++ {
ch := base[i]
if ch >= 'a' && ch <= 'z' {
shift := int(ch-'a') - letters
shift = ((shift % 26) + 26) % 26
decoded = append(decoded, byte('a'+shift))
continue
}
decoded = append(decoded, ch)
}
return string(decoded) + ".co.uk"
}
func fetchCFProxyDomains(url string, timeout time.Duration) ([]string, error) {
trimmedURL := strings.TrimSpace(url)
if trimmedURL == "" {
return nil, fmt.Errorf("empty cfproxy domains url")
}
fetchURL, err := cacheBustURL(trimmedURL)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: timeout}
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "tg-ws-proxy-go")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return nil, err
}
lines := strings.Split(string(body), "\n")
accepted := 0
pool := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
accepted++
domain := decodeCFProxyDomain(line)
if !isValidCFProxyDomain(domain) {
continue
}
pool = appendUniqueDomains(pool, domain)
}
if len(pool) < defaultCFProxyRefreshMinValidDomains {
return nil, fmt.Errorf("low-quality domain list from %s (total=%d valid=%d required>=%d)", trimmedURL, accepted, len(pool), defaultCFProxyRefreshMinValidDomains)
}
return pool, nil
}
func cacheBustURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", err
}
q := u.Query()
q.Set("cb", fmt.Sprintf("%d", time.Now().UnixNano()))
u.RawQuery = q.Encode()
return u.String(), nil
}
func startCFProxyDomainRefresh(cfg *Config) {
if cfg == nil || !cfg.FallbackCFProxy || !cfg.FallbackCFProxyRefresh || cfg.FallbackCFProxyUserDomain || strings.TrimSpace(cfg.FallbackCFProxyDomainsURL) == "" {
return
}
log.Printf("INFO CF proxy domain refresh scheduled: url=%s interval=%s", cfg.FallbackCFProxyDomainsURL, defaultCFProxyRefreshInterval)
go func() {
refresh := func() {
domains, err := fetchCFProxyDomains(cfg.FallbackCFProxyDomainsURL, defaultCFProxyRefreshTimeout)
if err != nil {
log.Printf("WARN CF proxy domain refresh failed: %v", err)
return
}
cfg.setCFProxyDomains(domains)
log.Printf("INFO CF proxy domain pool updated from GitHub (%d domains): %s", len(domains), strings.Join(domains, ", "))
}
refresh()
ticker := time.NewTicker(defaultCFProxyRefreshInterval)
defer ticker.Stop()
for range ticker.C {
refresh()
}
}()
}
func (cfg *Config) hasCFProxyDomains() bool {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()
return len(cfg.FallbackCFProxyDomains) > 0
}
func (cfg *Config) hasCFProxyWorkerDomains() bool {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()
return len(cfg.FallbackCFProxyWorkerDomains) > 0
}
func (cfg *Config) cfproxyWorkerDomainsForTry() []string {
cfg.cfproxyMu.RLock()
domains := append([]string(nil), cfg.FallbackCFProxyWorkerDomains...)
cfg.cfproxyMu.RUnlock()
return shuffledDomains(domains)
}
func (cfg *Config) cfproxyDomainsForTry(dc int) []string {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()
if len(cfg.FallbackCFProxyDomains) == 0 {
return nil
}
active := normalizeCFProxyDomain(cfg.FallbackCFProxyPerDCActive[dc])
if active == "" {
active = normalizeCFProxyDomain(cfg.FallbackCFProxyActive)
}
out := make([]string, 0, len(cfg.FallbackCFProxyDomains))
if active != "" {
for _, domain := range cfg.FallbackCFProxyDomains {
if domain == active {
out = append(out, domain)
break
}
}
}
for _, domain := range shuffledDomains(cfg.FallbackCFProxyDomains) {
if domain != active {
out = append(out, domain)
}
}
return out
}
func (cfg *Config) setCFProxyDomains(domains []string) {
pool := make([]string, 0, len(domains))
pool = appendUniqueDomains(pool, domains...)
if len(pool) == 0 {
pool = defaultCFProxyDomains()
}
active := chooseActiveDomain(pool)
cfg.cfproxyMu.Lock()
cfg.FallbackCFProxyDomains = pool
cfg.FallbackCFProxyActive = active
if cfg.FallbackCFProxyPerDCActive == nil {
cfg.FallbackCFProxyPerDCActive = make(map[int]string)
}
for _, dc := range cfProxyKnownDCs {
cfg.FallbackCFProxyPerDCActive[dc] = chooseActiveDomain(pool)
}
if active != "" {
cfg.FallbackCFProxyDomain = active
}
cfg.cfproxyMu.Unlock()
}
func (cfg *Config) promoteCFProxyDomain(dc int, domain string) {
normalized := normalizeCFProxyDomain(domain)
if normalized == "" {
return
}
cfg.cfproxyMu.Lock()
defer cfg.cfproxyMu.Unlock()
for _, existing := range cfg.FallbackCFProxyDomains {
if existing == normalized {
if cfg.FallbackCFProxyPerDCActive == nil {
cfg.FallbackCFProxyPerDCActive = make(map[int]string)
}
cfg.FallbackCFProxyPerDCActive[dc] = normalized
cfg.FallbackCFProxyActive = normalized
cfg.FallbackCFProxyDomain = normalized
return
}
}
}
func (cfg *Config) cfproxyActiveDomain() string {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()
return cfg.FallbackCFProxyActive
}
func (cfg *Config) cfproxyDomainPoolSize() int {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()
return len(cfg.FallbackCFProxyDomains)
}

View File

@ -0,0 +1,90 @@
package main
import (
"strings"
"testing"
)
func TestNormalizeCFProxyDomain(t *testing.T) {
cases := map[string]string{
" EXAMPLE.COM. ": "example.com",
"Foo.Bar": "foo.bar",
".leading": "leading",
"": "",
}
for in, want := range cases {
if got := normalizeCFProxyDomain(in); got != want {
t.Errorf("normalizeCFProxyDomain(%q) = %q, want %q", in, got, want)
}
}
}
func TestParseCFProxyDomainCSV(t *testing.T) {
got, err := parseCFProxyDomainCSV("a.tld, b.tld , a.tld,")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"a.tld", "b.tld"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("got %v, want %v (dedup + trim expected)", got, want)
}
}
func TestParseCFProxyDomainCSVInvalid(t *testing.T) {
if _, err := parseCFProxyDomainCSV("not_a_domain"); err == nil {
t.Error("expected error for invalid domain, got nil")
}
if _, err := parseCFProxyDomainCSV(" , , "); err == nil {
t.Error("expected error for empty pool, got nil")
}
}
func TestIsValidCFProxyDomain(t *testing.T) {
valid := []string{"foo.co.uk", "name-1234.user.workers.dev", "a.bc"}
for _, d := range valid {
if !isValidCFProxyDomain(d) {
t.Errorf("isValidCFProxyDomain(%q) = false, want true", d)
}
}
invalid := []string{"foo", "a..b", "-x.com", "x-.com", "1.2", ""}
for _, d := range invalid {
if isValidCFProxyDomain(d) {
t.Errorf("isValidCFProxyDomain(%q) = true, want false", d)
}
}
}
func TestIsLikelyDomain(t *testing.T) {
if !isLikelyDomain("a.b") {
t.Error("a.b should be likely")
}
if isLikelyDomain("ab") {
t.Error("ab (no dot) should not be likely")
}
if isLikelyDomain("a_b.c") {
t.Error("underscore should not be likely")
}
}
func TestDecodeCFProxyDomainPassthrough(t *testing.T) {
// Non-.com domains are passed through (normalized) unchanged.
for _, d := range []string{"example.co.uk", "kws1.web.telegram.org", "x.workers.dev"} {
if got := decodeCFProxyDomain(d); got != d {
t.Errorf("decodeCFProxyDomain(%q) = %q, want passthrough", d, got)
}
}
}
func TestDecodeCFProxyDomainComMapsToCoUk(t *testing.T) {
got := decodeCFProxyDomain("abcde.com")
if !strings.HasSuffix(got, ".co.uk") {
t.Errorf("decodeCFProxyDomain(.com) = %q, want .co.uk suffix", got)
}
}
func TestAppendUniqueDomains(t *testing.T) {
out := appendUniqueDomains(nil, "A.tld", "a.tld", "b.tld", "")
if strings.Join(out, ",") != "a.tld,b.tld" {
t.Errorf("appendUniqueDomains = %v, want [a.tld b.tld]", out)
}
}

71
src/cfproxy_state_test.go Normal file
View File

@ -0,0 +1,71 @@
package main
import "testing"
func TestSetAndTryCFProxyDomains(t *testing.T) {
cfg := &Config{}
cfg.setCFProxyDomains([]string{"a.tld", "b.tld", "a.tld"})
if !cfg.hasCFProxyDomains() {
t.Fatal("expected domains present")
}
if cfg.cfproxyDomainPoolSize() != 2 {
t.Fatalf("pool size = %d, want 2 (dedup)", cfg.cfproxyDomainPoolSize())
}
if cfg.cfproxyActiveDomain() == "" {
t.Fatal("active domain must be set")
}
order := cfg.cfproxyDomainsForTry(2)
if len(order) != 2 {
t.Fatalf("cfproxyDomainsForTry = %v, want 2 entries", order)
}
wantFirst := normalizeCFProxyDomain(cfg.FallbackCFProxyPerDCActive[2])
if wantFirst == "" {
wantFirst = cfg.cfproxyActiveDomain()
}
if order[0] != wantFirst {
t.Errorf("per-DC active must be first: order=%v wantFirst=%q", order, wantFirst)
}
seen := map[string]bool{order[0]: true, order[1]: true}
if !seen["a.tld"] || !seen["b.tld"] {
t.Errorf("both domains must appear: %v", order)
}
}
func TestSetCFProxyDomainsEmptyFallsBackToDefault(t *testing.T) {
cfg := &Config{}
cfg.setCFProxyDomains(nil)
if !cfg.hasCFProxyDomains() {
t.Fatal("empty input should fall back to default pool")
}
}
func TestPromoteCFProxyDomain(t *testing.T) {
cfg := &Config{}
cfg.setCFProxyDomains([]string{"a.tld", "b.tld"})
cfg.promoteCFProxyDomain(2, "b.tld")
if cfg.cfproxyActiveDomain() != "b.tld" {
t.Errorf("active = %q, want b.tld after promote", cfg.cfproxyActiveDomain())
}
// promoting an unknown domain must not change anything
cfg.promoteCFProxyDomain(2, "zzz.tld")
if cfg.cfproxyActiveDomain() != "b.tld" {
t.Error("unknown domain must not be promoted")
}
}
func TestCFProxyWorkerDomains(t *testing.T) {
cfg := &Config{}
if cfg.hasCFProxyWorkerDomains() {
t.Fatal("no worker domains by default")
}
cfg.FallbackCFProxyWorkerDomains = []string{"w1.workers.dev", "w2.workers.dev"}
if !cfg.hasCFProxyWorkerDomains() {
t.Fatal("expected worker domains present")
}
got := cfg.cfproxyWorkerDomainsForTry()
if len(got) != 2 {
t.Fatalf("worker domains = %v, want 2", got)
}
}

247
src/config.go Normal file
View File

@ -0,0 +1,247 @@
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
)
func parseFlags(args []string) (*Config, error) {
fs := flag.NewFlagSet("tg-ws-proxy", flag.ContinueOnError)
fs.SetOutput(io.Discard)
host := fs.String("host", "127.0.0.1", "Listen host")
port := fs.Int("port", 1443, "Listen port")
secret := fs.String("secret", "", "MTProto secret (32 hex chars)")
genSecret := fs.Bool("gen-secret", false, "Generate random secret and print it")
printLink := fs.Bool("print-link", false, "Print the tg:// connect link and exit")
verbose := fs.Bool("v", false, "Verbose logs")
logFile := fs.String("log-file", "", "Log file path")
logMaxMB := fs.Float64("log-max-mb", 5, "Max log file size before rotate")
logBackups := fs.Int("log-backups", 0, "Number of rotated backups")
bufKB := fs.Int("buf-kb", 256, "Socket buffer size in KB")
poolSize := fs.Int("pool-size", 4, "WS pool size per DC")
fakeTLSDomain := fs.String("fake-tls-domain", "", "Enable Fake TLS (ee-secret) with masking domain")
cfproxyDomain := fs.String("cfproxy-domain", defaultCFProxyDomain, "Cloudflare-proxied domain for WS fallback")
cfproxyDomains := fs.String("cfproxy-domains", "", "Comma-separated Cloudflare proxy domain pool for WS fallback")
cfproxyWorkerDomains := fs.String("cfproxy-worker-domain", "", "Comma-separated Cloudflare Worker domain(s) for WS fallback (e.g. name-1234.user.workers.dev); tried first when set")
noCfproxy := fs.Bool("no-cfproxy", false, "Disable Cloudflare proxy fallback")
cfproxyPriority := fs.Bool("cfproxy-priority", true, "Try cfproxy before TCP fallback")
noCfproxyDomainRefresh := fs.Bool("no-cfproxy-domain-refresh", false, "Disable periodic CF proxy domain refresh from URL")
cfproxyDomainsURL := fs.String("cfproxy-domains-url", "", "URL to fetch CF proxy domain list from")
maxConns := fs.Int("max-conns", defaultMaxConns, "Max concurrent client sessions")
dcIPDefault := fs.String("dc-ip-default", "149.154.167.220", "Default WS target IP for all implicit DCs when --dc-ip is not provided")
dcIPDefaultPool := fs.String("dc-ip-default-pool", "", "Default WS target IP pool for implicit DCs, comma-separated")
pprofListen := fs.String("pprof-listen", "", "Optional pprof listen address (e.g. 127.0.0.1:6060)")
var dcIPs multiFlag
var dcIPPools multiFlag
fs.Var(&dcIPs, "dc-ip", "Target DC IP as DC:IP; repeatable")
fs.Var(&dcIPPools, "dc-ip-pool", "Target pool as DC:IP1,IP2,...; repeatable")
if err := fs.Parse(args); err != nil {
return nil, err
}
provided := map[string]bool{}
fs.Visit(func(f *flag.Flag) { provided[f.Name] = true })
if *printLink && *secret == "" {
return nil, errors.New("--print-link requires --secret")
}
if *secret == "" {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
*secret = hex.EncodeToString(b)
if !*genSecret {
log.Printf("INFO Generated secret: %s", *secret)
}
}
if len(*secret) != 32 {
return nil, errors.New("secret must be exactly 32 hex chars")
}
if _, err := hex.DecodeString(*secret); err != nil {
return nil, errors.New("secret must be valid hex")
}
defaultTargetIP := strings.TrimSpace(*dcIPDefault)
if net.ParseIP(defaultTargetIP) == nil {
return nil, fmt.Errorf("invalid --dc-ip-default: %s", defaultTargetIP)
}
defaultPool := []string{defaultTargetIP}
if strings.TrimSpace(*dcIPDefaultPool) != "" {
poolIPs, err := parseIPCSV(*dcIPDefaultPool)
if err != nil {
return nil, fmt.Errorf("invalid --dc-ip-default-pool: %w", err)
}
defaultPool = poolIPs
}
dcMap := map[int]string{}
dcPool := map[int][]string{}
for _, dc := range []int{2, 4} {
dcPool[dc] = append([]string(nil), defaultPool...)
dcMap[dc] = defaultPool[0]
}
for _, item := range dcIPs {
parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid --dc-ip: %s", item)
}
dc, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid dc: %s", parts[0])
}
if net.ParseIP(parts[1]) == nil {
return nil, fmt.Errorf("invalid ip: %s", parts[1])
}
ip := parts[1]
dcPool[dc] = []string{ip}
dcMap[dc] = ip
}
for _, item := range dcIPPools {
parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid --dc-ip-pool: %s", item)
}
dc, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid dc in --dc-ip-pool: %s", parts[0])
}
poolIPs, err := parseIPCSV(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid --dc-ip-pool for dc %d: %w", dc, err)
}
dcPool[dc] = append([]string(nil), poolIPs...)
dcMap[dc] = dcPool[dc][0]
}
userDomainProvided := provided["cfproxy-domain"]
userPoolProvided := strings.TrimSpace(*cfproxyDomains) != ""
userDomain := normalizeCFProxyDomain(*cfproxyDomain)
userFixedDomain := userDomainProvided && userDomain != ""
domainPool := defaultCFProxyDomains()
if userPoolProvided {
if userDomainProvided {
return nil, errors.New("use only one of --cfproxy-domain or --cfproxy-domains")
}
parsedDomains, err := parseCFProxyDomainCSV(*cfproxyDomains)
if err != nil {
return nil, fmt.Errorf("invalid --cfproxy-domains: %w", err)
}
domainPool = parsedDomains
}
if userDomainProvided {
if strings.Contains(userDomain, ",") {
return nil, errors.New("invalid --cfproxy-domain: multiple domains are not allowed; use --cfproxy-domains for comma-separated pool")
}
if userDomain == "" {
return nil, errors.New("invalid --cfproxy-domain: empty domain")
}
domainPool = []string{userDomain}
} else if !userPoolProvided && userDomain != "" {
domainPool = appendUniqueDomains(domainPool, userDomain)
}
normalizedFakeTLSDomain := normalizeCFProxyDomain(*fakeTLSDomain)
if normalizedFakeTLSDomain != "" && !isLikelyDomain(normalizedFakeTLSDomain) {
return nil, fmt.Errorf("invalid --fake-tls-domain: %s", *fakeTLSDomain)
}
var workerDomains []string
if strings.TrimSpace(*cfproxyWorkerDomains) != "" {
wd, err := parseCFProxyDomainCSV(*cfproxyWorkerDomains)
if err != nil {
return nil, fmt.Errorf("invalid --cfproxy-worker-domain: %w", err)
}
workerDomains = wd
}
cfg := &Config{
Host: *host,
Port: *port,
SecretHex: *secret,
GenSecret: *genSecret,
PrintLink: *printLink,
FakeTLSDomain: normalizedFakeTLSDomain,
DCMap: dcMap,
DCPool: dcPool,
FallbackCFProxy: !*noCfproxy,
FallbackCFProxyPriority: *cfproxyPriority,
FallbackCFProxyDomain: "",
FallbackCFProxyUserDomain: userFixedDomain || userPoolProvided,
FallbackCFProxyRefresh: !*noCfproxyDomainRefresh,
FallbackCFProxyDomainsURL: strings.TrimSpace(*cfproxyDomainsURL),
FallbackCFProxyDomains: nil,
FallbackCFProxyWorkerDomains: workerDomains,
FallbackCFProxyActive: "",
FallbackCFProxyPerDCActive: make(map[int]string),
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),
}
cfg.setCFProxyDomains(domainPool)
return cfg, 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)
}

130
src/config_test.go Normal file
View File

@ -0,0 +1,130 @@
package main
import "testing"
const okSecret = "00112233445566778899aabbccddeeff"
func mustParse(t *testing.T, args ...string) *Config {
t.Helper()
cfg, err := parseFlags(args)
if err != nil {
t.Fatalf("parseFlags(%v) unexpected error: %v", args, err)
}
return cfg
}
func wantParseErr(t *testing.T, args ...string) {
t.Helper()
if _, err := parseFlags(args); err == nil {
t.Fatalf("parseFlags(%v) expected error, got nil", args)
}
}
func TestParseFlagsDefaults(t *testing.T) {
cfg := mustParse(t, "-secret", okSecret)
if cfg.Host != "127.0.0.1" || cfg.Port != 1443 {
t.Errorf("host/port = %s:%d", cfg.Host, cfg.Port)
}
if cfg.PoolSize != 4 || cfg.MaxConns != defaultMaxConns {
t.Errorf("poolSize=%d maxConns=%d", cfg.PoolSize, cfg.MaxConns)
}
if !cfg.FallbackCFProxy || !cfg.FallbackCFProxyPriority {
t.Error("CF proxy should be enabled, CF-first by default")
}
if cfg.DCPool[2][0] != "149.154.167.220" || len(cfg.DCPool[4]) == 0 {
t.Errorf("default DC pool = %v", cfg.DCPool)
}
if cfg.SecretHex != okSecret {
t.Errorf("secret = %q", cfg.SecretHex)
}
}
func TestParseFlagsGenSecret(t *testing.T) {
cfg := mustParse(t, "-gen-secret")
if !cfg.GenSecret || len(cfg.SecretHex) != 32 {
t.Errorf("gen-secret: GenSecret=%v len=%d", cfg.GenSecret, len(cfg.SecretHex))
}
}
func TestParseFlagsSecretValidation(t *testing.T) {
wantParseErr(t, "-secret", "tooshort")
wantParseErr(t, "-secret", "zz112233445566778899aabbccddeeff") // 32 chars, not hex
}
func TestParseFlagsPrintLink(t *testing.T) {
wantParseErr(t, "-print-link")
cfg := mustParse(t, "-print-link", "-secret", okSecret)
if !cfg.PrintLink {
t.Error("PrintLink should be set")
}
}
func TestParseFlagsDCIP(t *testing.T) {
cfg := mustParse(t, "-secret", okSecret, "-dc-ip", "1:1.2.3.4")
if len(cfg.DCPool[1]) != 1 || cfg.DCPool[1][0] != "1.2.3.4" {
t.Errorf("DCPool[1] = %v", cfg.DCPool[1])
}
wantParseErr(t, "-secret", okSecret, "-dc-ip", "1.2.3.4") // no colon
wantParseErr(t, "-secret", okSecret, "-dc-ip", "x:1.2.3.4") // bad dc
wantParseErr(t, "-secret", okSecret, "-dc-ip", "1:not-an-ip") // bad ip
}
func TestParseFlagsDCIPDefault(t *testing.T) {
wantParseErr(t, "-secret", okSecret, "-dc-ip-default", "999.999.999.999")
cfg := mustParse(t, "-secret", okSecret, "-dc-ip-default-pool", "5.5.5.5,6.6.6.6")
if len(cfg.DCPool[2]) != 2 {
t.Errorf("dc-ip-default-pool should fill DC2 pool, got %v", cfg.DCPool[2])
}
}
func TestParseFlagsCFProxy(t *testing.T) {
wantParseErr(t, "-secret", okSecret, "-cfproxy-domain", "a.tld", "-cfproxy-domains", "b.tld")
cfg := mustParse(t, "-secret", okSecret, "-cfproxy-domain", "mydomain.tld")
if !cfg.FallbackCFProxyUserDomain || cfg.cfproxyActiveDomain() != "mydomain.tld" {
t.Errorf("cfproxy-domain not applied: user=%v active=%q", cfg.FallbackCFProxyUserDomain, cfg.cfproxyActiveDomain())
}
cfg = mustParse(t, "-secret", okSecret, "-no-cfproxy")
if cfg.FallbackCFProxy {
t.Error("-no-cfproxy should disable CF proxy")
}
cfg = mustParse(t, "-secret", okSecret, "-cfproxy-priority=false")
if cfg.FallbackCFProxyPriority {
t.Error("-cfproxy-priority=false should disable CF-first")
}
}
func TestParseFlagsWorkerDomains(t *testing.T) {
cfg := mustParse(t, "-secret", okSecret, "-cfproxy-worker-domain", "w1.workers.dev,w2.workers.dev")
if len(cfg.FallbackCFProxyWorkerDomains) != 2 {
t.Errorf("worker domains = %v", cfg.FallbackCFProxyWorkerDomains)
}
wantParseErr(t, "-secret", okSecret, "-cfproxy-worker-domain", "not_a_domain")
}
func TestParseFlagsFakeTLS(t *testing.T) {
wantParseErr(t, "-secret", okSecret, "-fake-tls-domain", "nodot")
cfg := mustParse(t, "-secret", okSecret, "-fake-tls-domain", "mask.example.com")
if cfg.FakeTLSDomain != "mask.example.com" {
t.Errorf("FakeTLSDomain = %q", cfg.FakeTLSDomain)
}
}
func TestParseFlagsClamps(t *testing.T) {
cfg := mustParse(t, "-secret", okSecret, "-buf-kb", "1", "-max-conns", "0", "-pool-size", "-5")
if cfg.BufKB != 4 {
t.Errorf("buf-kb clamp: %d, want 4", cfg.BufKB)
}
if cfg.MaxConns != 1 {
t.Errorf("max-conns clamp: %d, want 1", cfg.MaxConns)
}
if cfg.PoolSize != 0 {
t.Errorf("pool-size clamp: %d, want 0", cfg.PoolSize)
}
}
func TestParseFlagsUnknownFlag(t *testing.T) {
wantParseErr(t, "-secret", okSecret, "-definitely-not-a-flag")
}

73
src/constants.go Normal file
View File

@ -0,0 +1,73 @@
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
dcBlacklistTTL = 10 * time.Minute
ioIdleTimeout = 90 * time.Second
wsWriteTimeout = 15 * time.Second
wsConnectTimeout = 10 * time.Second
wsConnectCooldownTimeout = 2 * time.Second
poolConnectTimeout = 8 * time.Second
clientHandshakeTimeout = 10 * time.Second
tcpDialTimeout = 10 * time.Second
fakeTLSWriteTimeout = 5 * time.Second
fakeTLSDrainGrace = 1 * time.Second
statsLogInterval = 60 * time.Second
statsFlushBytes = 256 * 1024
acceptPollTimeout = 1 * time.Second
acceptBackoffMin = 5 * time.Millisecond
acceptBackoffMax = 1 * time.Second
defaultMaxConns = 1024
defaultCFProxyDomain = "pclead.co.uk"
defaultCFProxyRefreshTimeout = 10 * time.Second
defaultCFProxyRefreshInterval = 1 * time.Hour
defaultCFProxyRefreshMinValidDomains = 3
)
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}
cfProxyDefaultDomainPool = []string{
defaultCFProxyDomain,
}
cfProxyKnownDCs = []int{1, 2, 3, 4, 5, 203}
)

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 ""
}

174
src/crypto_test.go Normal file
View File

@ -0,0 +1,174 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"testing"
)
func TestReverseBytes(t *testing.T) {
in := []byte{1, 2, 3, 4}
got := reverseBytes(in)
if !bytes.Equal(got, []byte{4, 3, 2, 1}) {
t.Errorf("reverseBytes = %v", got)
}
if !bytes.Equal(in, []byte{1, 2, 3, 4}) {
t.Error("reverseBytes mutated input")
}
}
func TestProtoFromTag(t *testing.T) {
if protoFromTag(protoTagAbridged) != protoAbridgedInt {
t.Error("abridged tag")
}
if protoFromTag(protoTagIntermediate) != protoIntermediateInt {
t.Error("intermediate tag")
}
if protoFromTag(protoTagSecure) != protoPaddedIntermediateInt {
t.Error("secure tag")
}
}
func TestSignedDC(t *testing.T) {
if signedDC(2, false) != 2 {
t.Error("non-media should be positive")
}
if signedDC(2, true) != -2 {
t.Error("media should be negative")
}
}
func TestWSDomains(t *testing.T) {
main := wsDomains(1, false)
if len(main) != 2 || main[0] != "kws1.web.telegram.org" || main[1] != "kws1-1.web.telegram.org" {
t.Errorf("wsDomains(1,false) = %v", main)
}
media := wsDomains(5, true)
if media[0] != "kws5-1.web.telegram.org" || media[1] != "kws5.web.telegram.org" {
t.Errorf("wsDomains(5,true) = %v", media)
}
}
func TestFallbackIP(t *testing.T) {
if fallbackIP(1) != "149.154.175.50" {
t.Errorf("fallbackIP(1) = %q", fallbackIP(1))
}
if fallbackIP(999) != "" {
t.Errorf("fallbackIP(999) should be empty, got %q", fallbackIP(999))
}
}
func TestKeyFromPrekeyAndSecretDeterministic(t *testing.T) {
prekey := bytes.Repeat([]byte{0xAB}, 32)
secret := []byte("0123456789abcdef")
a := keyFromPrekeyAndSecret(prekey, secret)
b := keyFromPrekeyAndSecret(prekey, secret)
if a != b {
t.Error("key derivation must be deterministic")
}
c := keyFromPrekeyAndSecret(bytes.Repeat([]byte{0xAC}, 32), secret)
if a == c {
t.Error("different prekey must yield different key")
}
}
// craftHandshake builds a 64-byte client handshake that decrypts (under secret)
// to the given proto tag and signed DC index, mirroring how a real client frames it.
func craftHandshake(t *testing.T, secret, protoTag []byte, dcIdx int16) []byte {
t.Helper()
hs := make([]byte, handshakeLen)
if _, err := rand.Read(hs[:protoTagPos]); err != nil {
t.Fatal(err)
}
prekey := hs[skipLen : skipLen+prekeyLen]
iv := hs[skipLen+prekeyLen : skipLen+prekeyLen+ivLen]
key := keyFromPrekeyAndSecret(prekey, secret)
block, err := aes.NewCipher(key[:])
if err != nil {
t.Fatal(err)
}
// keystream over the 64 bytes (tail currently zero -> out tail == keystream)
ks := cipher.NewCTR(block, iv)
out := make([]byte, handshakeLen)
ks.XORKeyStream(out, hs)
desired := make([]byte, 8)
copy(desired[:4], protoTag)
binary.LittleEndian.PutUint16(desired[4:6], uint16(dcIdx))
for i := 0; i < 8; i++ {
hs[protoTagPos+i] = desired[i] ^ out[protoTagPos+i]
}
return hs
}
func TestTryHandshakeRoundTrip(t *testing.T) {
secret := make([]byte, 16)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
hs := craftHandshake(t, secret, protoTagIntermediate, 2)
hi, ok := tryHandshake(hs, secret)
if !ok {
t.Fatal("expected valid handshake")
}
if hi.DC != 2 || hi.IsMedia {
t.Errorf("DC=%d media=%v, want DC=2 non-media", hi.DC, hi.IsMedia)
}
if !bytes.Equal(hi.ProtoTag, protoTagIntermediate) {
t.Errorf("proto tag = %v", hi.ProtoTag)
}
// media (negative DC index)
hsm := craftHandshake(t, secret, protoTagAbridged, -4)
him, ok := tryHandshake(hsm, secret)
if !ok || him.DC != 4 || !him.IsMedia {
t.Errorf("media handshake: ok=%v DC=%d media=%v", ok, him.DC, him.IsMedia)
}
// invalid proto tag -> rejected
bad := craftHandshake(t, secret, []byte{0x01, 0x02, 0x03, 0x04}, 2)
if _, ok := tryHandshake(bad, secret); ok {
t.Error("expected invalid proto tag to be rejected")
}
// wrong length -> rejected
if _, ok := tryHandshake(make([]byte, 10), secret); ok {
t.Error("short handshake must be rejected")
}
}
func TestBuildCiphersTranscodeRoundTrip(t *testing.T) {
secret := make([]byte, 16)
clientDecI := make([]byte, prekeyLen+ivLen)
relayInit := make([]byte, handshakeLen)
_, _ = rand.Read(secret)
_, _ = rand.Read(clientDecI)
_, _ = rand.Read(relayInit)
// Proxy ciphers and an identical "peer" set (same inputs -> same streams).
cltDec, _, tgEnc, _, err := buildCiphers(clientDecI, relayInit, secret)
if err != nil {
t.Fatal(err)
}
peerCltDec, _, peerTgEnc, _, err := buildCiphers(clientDecI, relayInit, secret)
if err != nil {
t.Fatal(err)
}
plain := []byte("the quick brown fox jumps over the lazy dog")
// client encrypts with its send stream (== proxy cltDec)
enc := make([]byte, len(plain))
peerCltDec.XORKeyStream(enc, plain)
// proxy decrypts then re-encrypts for Telegram
cltDec.XORKeyStream(enc, enc)
tgEnc.XORKeyStream(enc, enc)
// Telegram decrypts with its recv stream (== proxy tgEnc)
peerTgEnc.XORKeyStream(enc, enc)
if !bytes.Equal(enc, plain) {
t.Errorf("transcode round-trip mismatch: got %q want %q", enc, plain)
}
}

5
src/errors.go Normal file
View File

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

367
src/fake_tls.go Normal file
View File

@ -0,0 +1,367 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"log"
"math/big"
"net"
"time"
)
const (
tlsRecordCCS = 0x14
tlsRecordHandshake = 0x16
tlsRecordAppData = 0x17
tlsClientRandomOffset = 11
tlsClientRandomLen = 32
tlsSessionIDOffset = 44
tlsSessionIDLen = 32
tlsRecordMaxLen = 16384
tlsTimestampTolerance = 120
)
var (
fakeTLSCCSFrame = []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01}
fakeTLSServerHelloTemplate = []byte{
0x16, 0x03, 0x03, 0x00, 0x7a,
0x02, 0x00, 0x00, 0x76,
0x03, 0x03,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x20,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x13, 0x01, 0x00,
0x00, 0x2e,
0x00, 0x33, 0x00, 0x24, 0x00, 0x1d, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x2b, 0x00, 0x02, 0x03, 0x04,
}
)
type fakeTLSConn struct {
raw net.Conn
readBuf []byte
}
func fakeTLSConnectLink(host string, port int, secretHex, domain string) string {
return fmt.Sprintf(
"tg://proxy?server=%s&port=%d&secret=ee%s%s",
host,
port,
secretHex,
hex.EncodeToString([]byte(domain)),
)
}
func acceptFakeTLSClient(client net.Conn, secret []byte, maskingDomain string, label string) (net.Conn, []byte, bool) {
_ = client.SetReadDeadline(time.Now().Add(clientHandshakeTimeout))
first := make([]byte, 1)
if _, err := io.ReadFull(client, first); err != nil {
return nil, nil, false
}
if first[0] != tlsRecordHandshake {
_ = writeFakeTLSRedirect(client, maskingDomain)
return nil, nil, false
}
tlsHeader := make([]byte, 5)
tlsHeader[0] = first[0]
if _, err := io.ReadFull(client, tlsHeader[1:]); err != nil {
return nil, nil, false
}
recordLen := int(binary.BigEndian.Uint16(tlsHeader[3:5]))
if recordLen <= 0 || recordLen > 64*1024 {
return nil, nil, false
}
recordBody := make([]byte, recordLen)
if _, err := io.ReadFull(client, recordBody); err != nil {
return nil, nil, false
}
clientHello := append(tlsHeader, recordBody...)
clientRandom, sessionID, ok := verifyFakeTLSClientHello(clientHello, secret)
if !ok {
log.Printf("INFO [%s] Fake TLS verify failed -> masking", label)
proxyToMaskingDomain(client, clientHello, maskingDomain, label)
return nil, nil, false
}
serverHello, err := buildFakeTLSServerHello(secret, clientRandom, sessionID)
if err != nil {
log.Printf("WARN [%s] Fake TLS server hello build failed: %v", label, err)
return nil, nil, false
}
_ = client.SetWriteDeadline(time.Now().Add(clientHandshakeTimeout))
if _, err := client.Write(serverHello); err != nil {
return nil, nil, false
}
_ = client.SetWriteDeadline(time.Time{})
wrapped := &fakeTLSConn{raw: client}
hs := make([]byte, handshakeLen)
_ = wrapped.SetReadDeadline(time.Now().Add(clientHandshakeTimeout))
if _, err := io.ReadFull(wrapped, hs); err != nil {
return nil, nil, false
}
_ = wrapped.SetReadDeadline(time.Time{})
return wrapped, hs, true
}
func verifyFakeTLSClientHello(data []byte, secret []byte) ([]byte, []byte, bool) {
if len(data) < 43 {
return nil, nil, false
}
if data[0] != tlsRecordHandshake || data[5] != 0x01 {
return nil, nil, false
}
clientRandom := make([]byte, tlsClientRandomLen)
copy(clientRandom, data[tlsClientRandomOffset:tlsClientRandomOffset+tlsClientRandomLen])
zeroed := make([]byte, len(data))
copy(zeroed, data)
for i := 0; i < tlsClientRandomLen; i++ {
zeroed[tlsClientRandomOffset+i] = 0
}
expected := hmacSHA256(secret, zeroed)
if !hmac.Equal(expected[:28], clientRandom[:28]) {
return nil, nil, false
}
var tsXor [4]byte
for i := 0; i < 4; i++ {
tsXor[i] = clientRandom[28+i] ^ expected[28+i]
}
timestamp := int64(binary.LittleEndian.Uint32(tsXor[:]))
now := time.Now().Unix()
if now-timestamp > tlsTimestampTolerance || timestamp-now > tlsTimestampTolerance {
return nil, nil, false
}
sessionID := make([]byte, tlsSessionIDLen)
if len(data) >= tlsSessionIDOffset+tlsSessionIDLen && data[43] == 0x20 {
copy(sessionID, data[tlsSessionIDOffset:tlsSessionIDOffset+tlsSessionIDLen])
}
return clientRandom, sessionID, true
}
func buildFakeTLSServerHello(secret []byte, clientRandom []byte, sessionID []byte) ([]byte, error) {
sh := make([]byte, len(fakeTLSServerHelloTemplate))
copy(sh, fakeTLSServerHelloTemplate)
copy(sh[44:44+tlsSessionIDLen], sessionID)
pubKey := make([]byte, 32)
if _, err := rand.Read(pubKey); err != nil {
return nil, err
}
copy(sh[89:89+32], pubKey)
encSize, err := randomIntInRange(1900, 2100)
if err != nil {
return nil, err
}
encData := make([]byte, encSize)
if _, err := rand.Read(encData); err != nil {
return nil, err
}
appRecord := make([]byte, 5+encSize)
appRecord[0] = tlsRecordAppData
appRecord[1] = 0x03
appRecord[2] = 0x03
binary.BigEndian.PutUint16(appRecord[3:5], uint16(encSize))
copy(appRecord[5:], encData)
response := make([]byte, 0, len(sh)+len(fakeTLSCCSFrame)+len(appRecord))
response = append(response, sh...)
response = append(response, fakeTLSCCSFrame...)
response = append(response, appRecord...)
hmacInput := make([]byte, 0, len(clientRandom)+len(response))
hmacInput = append(hmacInput, clientRandom...)
hmacInput = append(hmacInput, response...)
serverRandom := hmacSHA256(secret, hmacInput)
copy(response[11:11+32], serverRandom)
return response, nil
}
func writeFakeTLSRedirect(client net.Conn, domain string) error {
resp := fmt.Sprintf(
"HTTP/1.1 301 Moved Permanently\r\nLocation: https://%s/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
domain,
)
_ = client.SetWriteDeadline(time.Now().Add(fakeTLSWriteTimeout))
_, err := client.Write([]byte(resp))
_ = client.SetWriteDeadline(time.Time{})
return err
}
func proxyToMaskingDomain(client net.Conn, initial []byte, domain string, label string) {
upstream, err := net.DialTimeout("tcp", net.JoinHostPort(domain, "443"), tcpDialTimeout)
if err != nil {
log.Printf("INFO [%s] masking connect failed: %v", label, err)
return
}
defer upstream.Close()
log.Printf("INFO [%s] masking -> %s:443", label, domain)
if len(initial) > 0 {
_ = upstream.SetWriteDeadline(time.Now().Add(fakeTLSWriteTimeout))
if _, err := upstream.Write(initial); err != nil {
return
}
}
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
_, _ = io.Copy(upstream, client)
_ = upstream.Close()
}()
go func() {
defer func() { done <- struct{}{} }()
_, _ = io.Copy(client, upstream)
_ = client.Close()
}()
<-done
_ = client.Close()
_ = upstream.Close()
select {
case <-done:
case <-time.After(fakeTLSDrainGrace):
}
}
func wrapFakeTLSRecords(data []byte) []byte {
if len(data) == 0 {
return nil
}
out := make([]byte, 0, len(data)+(len(data)/tlsRecordMaxLen+1)*5)
for len(data) > 0 {
chunk := data
if len(chunk) > tlsRecordMaxLen {
chunk = chunk[:tlsRecordMaxLen]
}
out = append(out, tlsRecordAppData, 0x03, 0x03, 0x00, 0x00)
binary.BigEndian.PutUint16(out[len(out)-2:], uint16(len(chunk)))
out = append(out, chunk...)
data = data[len(chunk):]
}
return out
}
func (c *fakeTLSConn) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if len(c.readBuf) > 0 {
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
payload, err := c.readTLSPayload()
if err != nil {
return 0, err
}
if len(payload) == 0 {
return 0, io.EOF
}
if len(payload) > len(p) {
n := copy(p, payload[:len(p)])
c.readBuf = append(c.readBuf[:0], payload[len(p):]...)
return n, nil
}
n := copy(p, payload)
return n, nil
}
func (c *fakeTLSConn) readTLSPayload() ([]byte, error) {
for {
hdr := make([]byte, 5)
if _, err := io.ReadFull(c.raw, hdr); err != nil {
return nil, err
}
recType := hdr[0]
recLen := int(binary.BigEndian.Uint16(hdr[3:5]))
if recType == tlsRecordCCS {
if recLen > 0 {
if _, err := io.CopyN(io.Discard, c.raw, int64(recLen)); err != nil {
return nil, err
}
}
continue
}
if recType != tlsRecordAppData {
return nil, io.EOF
}
if recLen == 0 {
continue
}
data := make([]byte, recLen)
if _, err := io.ReadFull(c.raw, data); err != nil {
return nil, err
}
return data, nil
}
}
func (c *fakeTLSConn) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
framed := wrapFakeTLSRecords(p)
if _, err := c.raw.Write(framed); err != nil {
return 0, err
}
return len(p), nil
}
func (c *fakeTLSConn) Close() error { return c.raw.Close() }
func (c *fakeTLSConn) LocalAddr() net.Addr { return c.raw.LocalAddr() }
func (c *fakeTLSConn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
func (c *fakeTLSConn) SetDeadline(t time.Time) error { return c.raw.SetDeadline(t) }
func (c *fakeTLSConn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
func (c *fakeTLSConn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) }
func hmacSHA256(key []byte, data []byte) []byte {
h := hmac.New(sha256.New, key)
_, _ = h.Write(data)
return h.Sum(nil)
}
func randomIntInRange(minVal, maxVal int) (int, error) {
if maxVal < minVal {
return 0, fmt.Errorf("invalid range")
}
width := maxVal - minVal + 1
n, err := rand.Int(rand.Reader, big.NewInt(int64(width)))
if err != nil {
return 0, err
}
return minVal + int(n.Int64()), nil
}

102
src/fake_tls_test.go Normal file
View File

@ -0,0 +1,102 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/binary"
"net"
"testing"
"time"
)
func TestFakeTLSFramingRoundTrip(t *testing.T) {
a, b := net.Pipe()
wc := &fakeTLSConn{raw: a}
rc := &fakeTLSConn{raw: b}
payload := make([]byte, 40000) // exceeds one TLS record (16384) -> multiple records
if _, err := rand.Read(payload); err != nil {
t.Fatal(err)
}
go func() {
_, _ = wc.Write(payload)
_ = a.Close()
}()
got := make([]byte, 0, len(payload))
buf := make([]byte, 4096)
for {
n, err := rc.Read(buf)
got = append(got, buf[:n]...)
if err != nil {
break
}
}
if !bytes.Equal(got, payload) {
t.Fatalf("framing round-trip mismatch: got %d bytes want %d", len(got), len(payload))
}
}
func TestVerifyFakeTLSClientHello(t *testing.T) {
secret := make([]byte, 16)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
data := make([]byte, 76)
data[0] = tlsRecordHandshake
data[5] = 0x01 // ClientHello
data[43] = 0x20 // session id length = 32
if _, err := rand.Read(data[tlsSessionIDOffset : tlsSessionIDOffset+tlsSessionIDLen]); err != nil {
t.Fatal(err)
}
// HMAC is computed over the record with the client-random region zeroed.
zeroed := make([]byte, len(data))
copy(zeroed, data)
for i := 0; i < tlsClientRandomLen; i++ {
zeroed[tlsClientRandomOffset+i] = 0
}
expected := hmacSHA256(secret, zeroed)
copy(data[tlsClientRandomOffset:tlsClientRandomOffset+28], expected[:28])
var tb [4]byte
binary.LittleEndian.PutUint32(tb[:], uint32(time.Now().Unix()))
for i := 0; i < 4; i++ {
data[tlsClientRandomOffset+28+i] = tb[i] ^ expected[28+i]
}
cr, sid, ok := verifyFakeTLSClientHello(data, secret)
if !ok {
t.Fatal("expected valid fake-TLS ClientHello")
}
if !bytes.Equal(cr, data[tlsClientRandomOffset:tlsClientRandomOffset+tlsClientRandomLen]) {
t.Error("client random mismatch")
}
if !bytes.Equal(sid, data[tlsSessionIDOffset:tlsSessionIDOffset+tlsSessionIDLen]) {
t.Error("session id mismatch")
}
// Tampering the HMAC region must fail verification.
data[tlsClientRandomOffset] ^= 0xFF
if _, _, ok := verifyFakeTLSClientHello(data, secret); ok {
t.Error("tampered ClientHello must be rejected")
}
}
func TestWrapFakeTLSRecordsStructure(t *testing.T) {
out := wrapFakeTLSRecords(bytes.Repeat([]byte{0xAA}, 5))
if len(out) != 5+5 {
t.Fatalf("wrapped len = %d, want 10 (5 header + 5 payload)", len(out))
}
if out[0] != tlsRecordAppData || out[1] != 0x03 || out[2] != 0x03 {
t.Error("bad record header")
}
if int(binary.BigEndian.Uint16(out[3:5])) != 5 {
t.Error("bad record length field")
}
if wrapFakeTLSRecords(nil) != nil {
t.Error("empty input should produce nil")
}
}

5
src/go.mod Normal file
View File

@ -0,0 +1,5 @@
module tg-ws-proxy
go 1.23.12
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("WARN "+format, args...)
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}

38
src/logging_test.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestRotatingWriter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
t.Fatal(err)
}
// maxMB tiny -> clamped to 32KB minimum.
w := newRotatingWriter(f, path, 0.001, 2)
defer func() { _ = w.f.Close() }() // release handle so Windows TempDir cleanup can unlink
line := make([]byte, 2000)
for i := 0; i < 200; i++ { // ~400KB, rotation checked every 32 writes
if _, err := w.Write(line); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(path + ".1"); err != nil {
t.Fatalf("expected rotated backup %s.1 to exist: %v", path, err)
}
// Current log file must still exist and be smaller than total written.
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Size() >= 400000 {
t.Errorf("active log not rotated, size=%d", st.Size())
}
}

99
src/misc_test.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/binary"
"testing"
"time"
)
func TestGenerateRelayInit(t *testing.T) {
ri := generateRelayInit(protoTagAbridged, 2)
if len(ri) != handshakeLen {
t.Fatalf("relayInit len = %d, want %d", len(ri), handshakeLen)
}
if reservedFirst[ri[0]] {
t.Error("first byte must not be a reserved value")
}
if bytes.Equal(ri[4:8], []byte{0, 0, 0, 0}) {
t.Error("bytes [4:8] must not be all-zero")
}
if bytes.Equal(ri, generateRelayInit(protoTagAbridged, 2)) {
t.Error("relayInit must be randomized per call")
}
}
func TestFakeTLSConnectLink(t *testing.T) {
got := fakeTLSConnectLink("1.2.3.4", 443, "00112233445566778899aabbccddeeff", "example.com")
want := "tg://proxy?server=1.2.3.4&port=443&secret=ee00112233445566778899aabbccddeeff6578616d706c652e636f6d"
if got != want {
t.Errorf("link = %q\nwant %q", got, want)
}
}
func TestParseIPCSV(t *testing.T) {
got, err := parseIPCSV("1.2.3.4, 5.6.7.8 , 1.2.3.4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 || got[0] != "1.2.3.4" || got[1] != "5.6.7.8" {
t.Errorf("parseIPCSV = %v, want dedup [1.2.3.4 5.6.7.8]", got)
}
if _, err := parseIPCSV("not-an-ip"); err == nil {
t.Error("expected error for invalid IP")
}
if _, err := parseIPCSV(" "); err == nil {
t.Error("expected error for empty pool")
}
}
func TestSplitterFlush(t *testing.T) {
ri := testRelayInit(t)
ms, err := newMsgSplitter(ri, protoIntermediateInt)
if err != nil {
t.Fatal(err)
}
plain := buildIntermediate(100) // 104-byte packet
ct := encForSplitter(t, ri, plain)
if parts := ms.split(ct[:10]); len(parts) != 0 {
t.Fatalf("incomplete packet should yield 0 parts, got %d", len(parts))
}
flushed := ms.flush()
if len(flushed) != 1 || !bytes.Equal(flushed[0], ct[:10]) {
t.Fatal("flush must return the buffered partial tail")
}
if got := ms.flush(); got != nil {
t.Error("second flush should be empty")
}
}
func TestVerifyFakeTLSStaleTimestamp(t *testing.T) {
secret := make([]byte, 16)
_, _ = rand.Read(secret)
data := make([]byte, 76)
data[0] = tlsRecordHandshake
data[5] = 0x01
data[43] = 0x20
_, _ = rand.Read(data[tlsSessionIDOffset : tlsSessionIDOffset+tlsSessionIDLen])
zeroed := make([]byte, len(data))
copy(zeroed, data)
for i := 0; i < tlsClientRandomLen; i++ {
zeroed[tlsClientRandomOffset+i] = 0
}
expected := hmacSHA256(secret, zeroed)
copy(data[tlsClientRandomOffset:tlsClientRandomOffset+28], expected[:28])
// timestamp far outside tolerance -> must be rejected even with valid HMAC
var tb [4]byte
binary.LittleEndian.PutUint32(tb[:], uint32(time.Now().Unix()-1000))
for i := 0; i < 4; i++ {
data[tlsClientRandomOffset+28+i] = tb[i] ^ expected[28+i]
}
if _, _, ok := verifyFakeTLSClientHello(data, secret); ok {
t.Error("stale timestamp must be rejected")
}
}

108
src/pool.go Normal file
View File

@ -0,0 +1,108 @@
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) *websocket.Conn {
now := time.Now()
for {
p.mu.Lock()
bucket := p.idle[key]
if len(bucket) == 0 {
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
atomic.AddInt64(&stats.poolMisses, 1)
return nil
}
item := bucket[0]
p.idle[key] = bucket[1:]
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
if now.Sub(item.Created) > wsPoolMaxAge {
_ = item.Conn.Close()
continue
}
atomic.AddInt64(&stats.poolHits, 1)
return item.Conn
}
}
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, poolConnectTimeout)
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))
}

59
src/pool_test.go Normal file
View File

@ -0,0 +1,59 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// dialTestWS opens a real client WS conn to a silent local server.
func dialTestWS(t *testing.T) (*websocket.Conn, func()) {
t.Helper()
upgrader := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
_, _, _ = c.ReadMessage() // block until client closes
}))
url := "ws" + strings.TrimPrefix(srv.URL, "http")
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
srv.Close()
t.Fatal(err)
}
return conn, func() { _ = conn.Close(); srv.Close() }
}
func TestPoolDiscardsAgedConn(t *testing.T) {
conn, cleanup := dialTestWS(t)
defer cleanup()
p := newWSPool()
key := dcKey{DC: 1}
p.idle[key] = []pooledWS{{Conn: conn, Created: time.Now().Add(-2 * wsPoolMaxAge)}}
cfg := &Config{PoolSize: 0} // PoolSize 0 -> no background refill
if got := p.get(cfg, key, "1.2.3.4", []string{"d"}); got != nil {
t.Error("aged pooled conn must be discarded (get -> nil)")
}
}
func TestPoolReturnsFreshConn(t *testing.T) {
conn, cleanup := dialTestWS(t)
defer cleanup()
p := newWSPool()
key := dcKey{DC: 1}
p.idle[key] = []pooledWS{{Conn: conn, Created: time.Now()}}
cfg := &Config{PoolSize: 0}
if got := p.get(cfg, key, "1.2.3.4", []string{"d"}); got != conn {
t.Error("fresh pooled conn must be returned as-is")
}
}

362
src/server.go Normal file
View File

@ -0,0 +1,362 @@
package main
import (
"encoding/hex"
"fmt"
"io"
"log"
"net"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
func main() {
cfg, err := parseFlags(os.Args[1:])
if err != nil {
log.Fatalf("config error: %v", err)
}
if cfg.GenSecret {
fmt.Println(cfg.SecretHex)
return
}
if cfg.PrintLink {
linkHost := getLinkHost(cfg.Host)
if cfg.FakeTLSDomain != "" {
fmt.Println(fakeTLSConnectLink(linkHost, cfg.Port, cfg.SecretHex, cfg.FakeTLSDomain))
} else {
fmt.Printf("tg://proxy?server=%s&port=%d&secret=dd%s\n", linkHost, cfg.Port, cfg.SecretHex)
}
return
}
initLogger(cfg)
startPprof(cfg)
startCFProxyDomainRefresh(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)
if cfg.FakeTLSDomain != "" {
log.Printf("INFO Fake TLS: %s", cfg.FakeTLSDomain)
}
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)
}
if cfg.FallbackCFProxy {
prio := "TCP first"
if cfg.FallbackCFProxyPriority {
prio = "CF first"
}
refreshMode := "off"
if cfg.FallbackCFProxyRefresh && !cfg.FallbackCFProxyUserDomain && strings.TrimSpace(cfg.FallbackCFProxyDomainsURL) != "" {
refreshMode = "startup"
}
log.Printf("INFO CF proxy: active=%s pool=%d (%s, refresh=%s)", cfg.cfproxyActiveDomain(), cfg.cfproxyDomainPoolSize(), prio, refreshMode)
}
if cfg.hasCFProxyWorkerDomains() {
log.Printf("INFO CF worker: %s (tried first)", strings.Join(cfg.FallbackCFProxyWorkerDomains, ", "))
}
log.Printf("INFO %s", strings.Repeat("=", 60))
log.Printf("INFO Connect link:")
if cfg.FakeTLSDomain != "" {
log.Printf("INFO %s", fakeTLSConnectLink(linkHost, cfg.Port, cfg.SecretHex, cfg.FakeTLSDomain))
} else {
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(statsLogInterval)
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 }()
defer func() {
if r := recover(); r != nil {
_ = conn.Close()
log.Printf("ERROR [%s] panic recovered: %v", conn.RemoteAddr(), r)
}
}()
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)
handshakeConn := client
if cfg.FakeTLSDomain != "" {
fconn, hs, ok := acceptFakeTLSClient(client, secret, cfg.FakeTLSDomain, label)
if !ok {
return
}
handshakeConn = fconn
hi, ok := tryHandshake(hs, secret)
if !ok {
atomic.AddInt64(&stats.connectionsBad, 1)
debugf(cfg, "[%s] bad handshake", label)
return
}
handleMTProtoClient(handshakeConn, cfg, hi, secret, label)
return
}
_ = handshakeConn.SetReadDeadline(time.Now().Add(clientHandshakeTimeout))
hs := make([]byte, handshakeLen)
if _, err := io.ReadFull(handshakeConn, hs); err != nil {
debugf(cfg, "[%s] client disconnected before handshake", label)
return
}
_ = handshakeConn.SetReadDeadline(time.Time{})
hi, ok := tryHandshake(hs, secret)
if !ok {
atomic.AddInt64(&stats.connectionsBad, 1)
debugf(cfg, "[%s] bad handshake", label)
return
}
handleMTProtoClient(handshakeConn, cfg, hi, secret, label)
}
func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret []byte, label string) {
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
}
newFallbackSplitter := func() *msgSplitter {
ms, err := newMsgSplitter(relayInit, protoInt)
if err != nil {
return nil
}
return ms
}
doFallback := func(setState bool, wsFailedRedirect bool, allRedirect bool, primaryTarget string) {
key := dcKey{DC: hi.DC, IsMedia: hi.IsMedia}
if setState {
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
}
useWorker := cfg.hasCFProxyWorkerDomains()
tryWorker := func() bool {
splitter := newFallbackSplitter()
if err := cfWorkerFallback(label, cfg, hi.DC, hi.IsMedia, fallback, client, relayInit, cltDec, cltEnc, tgEnc, tgDec, splitter); err == nil {
log.Printf("INFO [%s] DC%d%s CF worker fallback closed", label, hi.DC, mediaTag)
return true
}
return false
}
useCF := cfg.FallbackCFProxy && cfg.hasCFProxyDomains()
tryCF := func() bool {
splitter := newFallbackSplitter()
if err := cfproxyFallback(label, cfg, hi.DC, hi.IsMedia, client, relayInit, cltDec, cltEnc, tgEnc, tgDec, splitter); err == nil {
log.Printf("INFO [%s] DC%d%s CF proxy fallback closed", label, hi.DC, mediaTag)
return true
}
return false
}
tryTCP := func() bool {
if fallback == "" {
return false
}
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)
return true
}
return false
}
if useWorker && tryWorker() {
return
}
if useCF && cfg.FallbackCFProxyPriority {
if tryCF() || tryTCP() {
return
}
} else if useCF {
if tryTCP() || tryCF() {
return
}
} else if tryTCP() {
return
}
log.Printf("WARN [%s] DC%d%s no fallback available", label, hi.DC, mediaTag)
}
if isBlacklisted(hi.DC, hi.IsMedia) {
log.Printf("INFO [%s] DC%d%s WS blacklisted -> fallback", label, hi.DC, mediaTag)
doFallback(false, false, false, "")
return
}
targets, hasTarget := cfg.DCPool[hi.DC]
if !hasTarget || len(targets) == 0 {
log.Printf("INFO [%s] DC%d%s not in config -> fallback", label, hi.DC, mediaTag)
doFallback(false, false, false, "")
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 := dialWS(target, 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
}
dialFresh := func() *websocket.Conn {
timeout := wsConnectTimeout
if inCooldown(key) {
timeout = wsConnectCooldownTimeout
}
conn, wsFailedRedirect, allRedirect := connectWS(timeout)
if conn == nil {
doFallback(true, wsFailedRedirect, allRedirect, primaryTarget)
}
return conn
}
ws := pool.get(cfg, key, primaryTarget, domains)
fromPool := ws != nil
if fromPool {
log.Printf("INFO [%s] DC%d%s -> pool hit via %s", label, hi.DC, mediaTag, primaryTarget)
} else if ws = dialFresh(); ws == nil {
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)
doFallback(false, false, false, primaryTarget)
return
}
if ws = dialFresh(); ws == nil {
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)
doFallback(false, false, false, primaryTarget)
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
}

136
src/splitter_test.go Normal file
View File

@ -0,0 +1,136 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"testing"
)
func testRelayInit(t *testing.T) []byte {
t.Helper()
ri := make([]byte, handshakeLen)
if _, err := rand.Read(ri); err != nil {
t.Fatal(err)
}
return ri
}
func encForSplitter(t *testing.T, relayInit, plain []byte) []byte {
t.Helper()
block, err := aes.NewCipher(relayInit[8:40])
if err != nil {
t.Fatal(err)
}
enc := cipher.NewCTR(block, relayInit[40:56])
tmp := make([]byte, handshakeLen)
enc.XORKeyStream(tmp, make([]byte, handshakeLen))
out := make([]byte, len(plain))
enc.XORKeyStream(out, plain)
return out
}
func buildIntermediate(sizes ...int) []byte {
var out []byte
for _, s := range sizes {
h := make([]byte, 4)
binary.LittleEndian.PutUint32(h, uint32(s))
out = append(out, h...)
out = append(out, make([]byte, s)...)
}
return out
}
func TestSplitterIntermediateWhole(t *testing.T) {
ri := testRelayInit(t)
ms, err := newMsgSplitter(ri, protoIntermediateInt)
if err != nil {
t.Fatal(err)
}
plain := buildIntermediate(8, 12) // packets of 4+8=12 and 4+12=16
ct := encForSplitter(t, ri, plain)
parts := ms.split(ct)
if len(parts) != 2 {
t.Fatalf("got %d parts, want 2", len(parts))
}
if len(parts[0]) != 12 || len(parts[1]) != 16 {
t.Fatalf("part lens = %d,%d want 12,16", len(parts[0]), len(parts[1]))
}
joined := append(append([]byte{}, parts[0]...), parts[1]...)
if !bytes.Equal(joined, ct) {
t.Error("parts must reconstruct the original ciphertext")
}
}
func TestSplitterIntermediateFragmented(t *testing.T) {
ri := testRelayInit(t)
ms, err := newMsgSplitter(ri, protoIntermediateInt)
if err != nil {
t.Fatal(err)
}
plain := buildIntermediate(8, 12)
ct := encForSplitter(t, ri, plain)
// First 6 bytes: not even one full packet -> no parts yet.
if parts := ms.split(ct[:6]); len(parts) != 0 {
t.Fatalf("partial feed returned %d parts, want 0", len(parts))
}
// Feed the rest -> both packets emerge.
parts := ms.split(ct[6:])
if len(parts) != 2 {
t.Fatalf("after rest got %d parts, want 2", len(parts))
}
}
func TestSplitterAbridged(t *testing.T) {
ri := testRelayInit(t)
ms, err := newMsgSplitter(ri, protoAbridgedInt)
if err != nil {
t.Fatal(err)
}
// abridged: 1-byte length n (words), payload n*4 bytes. packet = 1 + n*4.
plain := []byte{}
plain = append(plain, 2) // payload 8 bytes
plain = append(plain, make([]byte, 8)...) //
plain = append(plain, 3) // payload 12 bytes
plain = append(plain, make([]byte, 12)...) //
ct := encForSplitter(t, ri, plain)
parts := ms.split(ct)
if len(parts) != 2 || len(parts[0]) != 9 || len(parts[1]) != 13 {
t.Fatalf("abridged parts = %v lens, want 9 and 13", lensOf(parts))
}
}
func TestSplitterDisableOnZeroLen(t *testing.T) {
ri := testRelayInit(t)
ms, err := newMsgSplitter(ri, protoIntermediateInt)
if err != nil {
t.Fatal(err)
}
// A zero-length packet header makes nextPacketLen return 0 -> splitter
// disables and passes everything through unchanged afterwards.
plain := buildIntermediate(0)
ct := encForSplitter(t, ri, plain)
parts := ms.split(ct)
if len(parts) != 1 || !bytes.Equal(parts[0], ct) {
t.Fatalf("expected single passthrough part on zero-len")
}
// Now disabled: arbitrary bytes pass straight through.
extra := []byte{9, 9, 9}
parts = ms.split(extra)
if len(parts) != 1 || !bytes.Equal(parts[0], extra) {
t.Fatal("expected passthrough after disable")
}
}
func lensOf(parts [][]byte) []int {
out := make([]int, len(parts))
for i, p := range parts {
out[i] = len(p)
}
return out
}

64
src/state.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"sort"
"sync"
"time"
)
var (
stats Stats
pool = newWSPool()
blacklist = make(map[dcKey]time.Time)
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] = time.Now().Add(dcBlacklistTTL)
blMu.Unlock()
}
func isBlacklisted(dc int, media bool) bool {
k := dcKey{DC: dc, IsMedia: media}
blMu.Lock()
t, ok := blacklist[k]
if ok && !time.Now().Before(t) {
delete(blacklist, k)
ok = false
}
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
}

49
src/state_test.go Normal file
View File

@ -0,0 +1,49 @@
package main
import (
"testing"
"time"
)
func TestCooldown(t *testing.T) {
k := dcKey{DC: 1, IsMedia: false}
clearCooldown(k)
if inCooldown(k) {
t.Fatal("not in cooldown after clear")
}
setCooldown(k)
if !inCooldown(k) {
t.Fatal("expected in cooldown after set")
}
clearCooldown(k)
if inCooldown(k) {
t.Fatal("expected not in cooldown after clear")
}
}
func TestBlacklistTTL(t *testing.T) {
k := dcKey{DC: 2, IsMedia: true}
setBlacklisted(k)
if !isBlacklisted(2, true) {
t.Fatal("expected blacklisted right after set")
}
if isBlacklisted(3, false) {
t.Fatal("unrelated key must not be blacklisted")
}
// Force expiry by rewinding the stored deadline into the past.
blMu.Lock()
blacklist[k] = time.Now().Add(-time.Minute)
blMu.Unlock()
if isBlacklisted(2, true) {
t.Fatal("expected expired blacklist entry to report false")
}
// Expired entry must be lazily removed.
blMu.Lock()
_, ok := blacklist[k]
blMu.Unlock()
if ok {
t.Fatal("expired blacklist entry should be deleted")
}
}

378
src/transport.go Normal file
View File

@ -0,0 +1,378 @@
package main
import (
"context"
"crypto/cipher"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"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, KeepAliveConfig: tcpKeepAliveConfig}
return d.DialContext(ctx, "tcp", net.JoinHostPort(targetIP, "443"))
},
}
headers := http.Header{}
headers.Set("Host", domain)
headers.Set("Origin", "https://web.telegram.org")
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{}{} }()
buf := ioBufPool.Get().([]byte)
defer ioBufPool.Put(buf)
var downPending int64
defer func() {
if downPending > 0 {
atomic.AddInt64(&stats.bytesDown, downPending)
}
}()
for {
_ = ws.SetReadDeadline(time.Now().Add(ioIdleTimeout))
mt, r, err := ws.NextReader()
if err != nil {
return
}
if mt != websocket.BinaryMessage {
continue
}
downPkts++
for {
nr, rerr := r.Read(buf)
if nr > 0 {
n := int64(nr)
downPending += n
downBytes += n
if downPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesDown, downPending)
downPending = 0
}
chunk := buf[:nr]
tgDec.XORKeyStream(chunk, chunk)
cltEnc.XORKeyStream(chunk, chunk)
_ = client.SetWriteDeadline(time.Now().Add(ioIdleTimeout))
if _, werr := client.Write(chunk); werr != nil {
return
}
}
if rerr != nil {
if rerr == io.EOF {
break
}
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"), tcpDialTimeout)
if err != nil {
warnf("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
}
func dialWSByDomain(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,
},
}
headers := http.Header{}
headers.Set("Host", domain)
headers.Set("Origin", "https://web.telegram.org")
return dialer.Dial(u.String(), headers)
}
func dialWSWorker(worker, dst string, dc int, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
q := url.Values{}
q.Set("dst", dst)
q.Set("dc", strconv.Itoa(dc))
u := url.URL{Scheme: "wss", Host: worker, Path: "/apiws", RawQuery: q.Encode()}
dialer := websocket.Dialer{
HandshakeTimeout: timeout,
Subprotocols: []string{"binary"},
TLSClientConfig: &tls.Config{
ServerName: worker,
InsecureSkipVerify: true,
},
}
headers := http.Header{}
headers.Set("Host", worker)
headers.Set("Origin", "https://web.telegram.org")
return dialer.Dial(u.String(), headers)
}
func cfWorkerFallback(label string, cfg *Config, dc int, isMedia bool, dst string, client net.Conn, relayInit []byte, cltDec, cltEnc, tgEnc, tgDec cipher.Stream, splitter *msgSplitter) error {
mediaTag := ""
if isMedia {
mediaTag = " media"
}
if dst == "" {
return errNoDomains
}
for _, worker := range cfg.cfproxyWorkerDomainsForTry() {
logf("INFO [%s] DC%d%s -> CF worker wss://%s/apiws?dst=%s", label, dc, mediaTag, worker, dst)
ws, _, err := dialWSWorker(worker, dst, dc, wsConnectTimeout)
if err != nil {
atomic.AddInt64(&stats.wsErrors, 1)
warnf("[%s] DC%d%s CF worker %s failed: %v", label, dc, mediaTag, worker, err)
continue
}
if err := ws.WriteMessage(websocket.BinaryMessage, relayInit); err != nil {
_ = ws.Close()
warnf("[%s] DC%d%s CF worker init write failed: %v", label, dc, mediaTag, err)
continue
}
atomic.AddInt64(&stats.connectionsCF, 1)
bridgeWS(label, dc, isMedia, client, ws, cltDec, cltEnc, tgEnc, tgDec, splitter)
return nil
}
return errNoDomains
}
func cfproxyFallback(label string, cfg *Config, dc int, isMedia bool, client net.Conn, relayInit []byte, cltDec, cltEnc, tgEnc, tgDec cipher.Stream, splitter *msgSplitter) error {
mediaTag := ""
if isMedia {
mediaTag = " media"
}
for _, baseDomain := range cfg.cfproxyDomainsForTry(dc) {
domain := fmt.Sprintf("kws%d.%s", dc, baseDomain)
logf("INFO [%s] DC%d%s -> CF proxy wss://%s/apiws", label, dc, mediaTag, domain)
ws, resp, err := dialWSByDomain(domain, wsConnectTimeout)
if err != nil {
atomic.AddInt64(&stats.wsErrors, 1)
if resp != nil && isRedirect(resp.StatusCode) {
warnf("[%s] DC%d%s CF proxy got %d from %s", label, dc, mediaTag, resp.StatusCode, domain)
} else {
warnf("[%s] DC%d%s CF proxy %s failed: %v", label, dc, mediaTag, domain, err)
}
continue
}
if err := ws.WriteMessage(websocket.BinaryMessage, relayInit); err != nil {
_ = ws.Close()
warnf("[%s] DC%d%s CF proxy init write failed: %v", label, dc, mediaTag, err)
continue
}
atomic.AddInt64(&stats.connectionsCF, 1)
cfg.promoteCFProxyDomain(dc, baseDomain)
bridgeWS(label, dc, isMedia, client, ws, cltDec, cltEnc, tgEnc, tgDec, splitter)
return nil
}
return errNoDomains
}

91
src/types.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct {
Host string
Port int
SecretHex string
GenSecret bool
PrintLink bool
FakeTLSDomain string
DCMap map[int]string
DCPool map[int][]string
FallbackCFProxy bool
FallbackCFProxyPriority bool
FallbackCFProxyDomain string
FallbackCFProxyUserDomain bool
FallbackCFProxyRefresh bool
FallbackCFProxyDomainsURL string
FallbackCFProxyDomains []string
FallbackCFProxyWorkerDomains []string
FallbackCFProxyActive string
FallbackCFProxyPerDCActive map[int]string
Verbose bool
BufKB int
PoolSize int
MaxConns int
LogFile string
LogMaxMB float64
LogBackups int
PprofListen string
cfproxyMu sync.RWMutex
}
type Stats struct {
connectionsTotal int64
connectionsActive int64
connectionsWS int64
connectionsTCP int64
connectionsCF 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 cf=%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.connectionsCF),
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
}

62
src/utils.go Normal file
View File

@ -0,0 +1,62 @@
package main
import (
"fmt"
"math"
"net"
"time"
)
var tcpKeepAliveConfig = net.KeepAliveConfig{
Enable: true,
Idle: 10 * time.Second,
Interval: 5 * time.Second,
Count: 3,
}
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)
_ = tcp.SetKeepAliveConfig(tcpKeepAliveConfig)
return nil
}

35
src/utils_test.go Normal file
View File

@ -0,0 +1,35 @@
package main
import "testing"
func TestHumanBytes(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0.0B"},
{512, "512.0B"},
{1024, "1.0KB"},
{1536, "1.5KB"},
{1048576, "1.0MB"},
{1073741824, "1.0GB"},
}
for _, c := range cases {
if got := humanBytes(c.in); got != c.want {
t.Errorf("humanBytes(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestIsRedirect(t *testing.T) {
for _, code := range []int{301, 302, 303, 307, 308} {
if !isRedirect(code) {
t.Errorf("isRedirect(%d) = false, want true", code)
}
}
for _, code := range []int{200, 204, 400, 404, 500, 0} {
if isRedirect(code) {
t.Errorf("isRedirect(%d) = true, want false", code)
}
}
}

View File

@ -1,790 +0,0 @@
from __future__ import annotations
import ctypes
import json
import logging
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,
}
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray")
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return os.path.basename(sys.executable) == proc.name()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S"))
root.addHandler(ch)
def _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)
_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, 460
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))
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),
}
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.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()