96 Commits

Author SHA1 Message Date
10f84c7f34 Release 0.9.2 2026-07-12 23:14:58 +03:00
a182c89b6d Release 0.9.1 2026-07-01 21:38:18 +03:00
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
4ae7cb92f7 autostart fixes 2026-03-19 12:26:31 +03:00
7eeb447a76 add windows autostart (#171) 2026-03-19 11:27:59 +03:00
5d839c1112 fix for default dc options 2026-03-19 11:09:07 +03:00
0dc2a9cac6 built files rename 2026-03-19 07:43:42 +03:00
7943c539b6 .deb build test 2026-03-19 07:28:46 +03:00
5e53a8a470 unused import 2026-03-19 07:03:11 +03:00
692157b0f5 Linux binary, github actions (#282) 2026-03-19 06:55:55 +03:00
26542558c6 dc fail logic rewrite for independent usability 2026-03-19 06:23:58 +03:00
e6ee4e6159 Hardcoded dc override for 203 2026-03-19 05:53:14 +03:00
96383057c6 dc203 for possible overriding 2026-03-19 05:42:40 +03:00
646468680c Speed improvements 2026-03-19 02:36:17 +03:00
51aca9009f removed req files 2026-03-18 22:03:57 +03:00
6b9ddda7f0 readme simplify 2026-03-18 21:58:35 +03:00
54c6f3881b pyproject fixes; macos support 2026-03-18 21:54:58 +03:00
99b5c722e1 build: migrate deps to pyproject.toml (#201) 2026-03-18 21:33:12 +03:00
9924440c48 Update macos.py (#272) 2026-03-18 20:27:16 +03:00
7572258a28 MacOS build simplify, readme update 2026-03-18 19:22:46 +03:00
d2190cfec6 cffi universal2 fix 2026-03-18 18:15:06 +03:00
053ec3e00f Universal2 macos test 2026-03-18 18:11:07 +03:00
55affaf78f macos dialog fix; macos merge logs 2026-03-18 17:49:24 +03:00
533420b516 MacOS support (#225) 2026-03-18 17:33:38 +03:00
473078593a Merge pull request #244 from hir-lol/main 2026-03-18 01:40:09 +03:00
46011c0ff5 Github optional release on build 2026-03-17 22:18:21 +03:00
8219b9f144 pyinstaller changed to previous version for false detect prevention 2026-03-17 22:15:04 +03:00
cf3e3b2aec typos 2026-03-16 04:09:39 +03:00
3fdce27fbb Media chunking fix; Removed high number dc detection 2026-03-16 04:04:54 +03:00
1433c2e881 typo in readme 2026-03-15 15:55:23 +03:00
f774777539 Merge pull request #141 from nullptr-deref/main 2026-03-15 15:50:57 +03:00
b6cb5aa76f general typos fix 2026-03-15 15:29:19 +03:00
7574357db9 update readme 2026-03-15 14:17:07 +03:00
2571847a9e issue template 2026-03-15 14:10:04 +03:00
f5d7797259 build fix 2026-03-15 05:19:15 +03:00
d5a3eb5157 build fix 2026-03-15 05:06:16 +03:00
e4891cfd53 hardcode host connection 2026-03-15 05:00:50 +03:00
a0a5bfbecb IPv6 warnings 2026-03-15 04:56:26 +03:00
1c227b924a Optimization, connections pool 2026-03-15 04:34:05 +03:00
72e5040e6d fix #83 2026-03-15 02:33:20 +03:00
0297bf8305 Unstripped build 2026-03-15 01:44:37 +03:00
8bcbcd2787 media dc fix on mobiles 2026-03-13 13:34:22 +03:00
f744e93de6 Mobiles media fix, optimizations 2026-03-12 19:36:02 +03:00
6147cda356 unknown behavior on mobiles with media dcs 2026-03-10 14:21:31 +03:00
3cf12467a7 Host configuration 2026-03-07 21:52:59 +03:00
48282a63d4 code cleaning 2026-03-07 21:14:17 +03:00
39dd71be14 Lock recode, bind error notify, clipboard cross-platform 2026-03-07 21:10:35 +03:00
46aec5e3b6 Win7 bundle 2026-03-07 18:08:42 +03:00
7e3732b04b reqs version freeze 2026-03-07 17:18:05 +03:00
5586d194db workflow windows path fix 2026-03-06 19:59:32 +03:00
f69d20ad85 Restructure 2026-03-06 19:48:12 +03:00
01b3aca85e code simplify 2026-03-06 19:08:46 +03:00
9e9448dda0 imports clear 2026-03-06 17:13:00 +03:00
f8a10d9940 Mapping unknown DC by IP for mobile clients 2026-03-06 02:47:59 +03:00
e57f61a621 unused const 2026-03-05 20:42:29 +03:00
97 changed files with 5531 additions and 1714 deletions

4
.config.example Normal file
View File

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

20
.github/cfproxy-domains.txt vendored Normal file
View File

@ -0,0 +1,20 @@
virkgj.com
vmmzovy.com
mkuosckvso.com
zaewayzmplad.com
twdmbzcm.com
awzwsldi.com
clngqrflngqin.com
tjacxbqtj.com
bxaxtxmrw.com
dmohrsgmohcrwb.com
vwbmtmoi.com
khgrre.com
ulihssf.com
tmhqsdqmfpmk.com
xwuwoqbm.com
orgcnunpj.com
zhkuldz.com
zypoljnslxa.com
efabnxaowuzs.com
zaftuzsftqdq.com

View File

@ -1,50 +1,245 @@
name: Build & Release
name: Build tg-ws-proxy
on:
workflow_dispatch:
inputs:
version:
description: "Release version tag (e.g. v1.0.0)"
required: true
default: "v1.0.0"
release:
types: [created]
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
prepare:
name: Prepare build matrix
runs-on: ubuntu-latest
outputs:
configs: ${{ steps.collect.outputs.configs }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v5
- name: Build config matrix
id: collect
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
from pathlib import Path
configs = sorted(Path("config").glob("*/*.config"))
if not configs:
raise SystemExit("No build configs found")
matrix = []
for config in configs:
matrix.append({
"config": config.as_posix(),
"name": config.stem,
})
print(f"configs={json.dumps(matrix, separators=(',', ':'))}")
PY
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
python-version: "3.12"
cache: "pip"
go-version-file: src/go.mod
cache: true
cache-dependency-path: src/go.sum
- name: 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: pip install -r requirements.txt
run: |
sudo apt-get update
sudo apt-get install -y dos2unix
- name: Build EXE with PyInstaller
run: pyinstaller tg_ws_proxy.spec --noconfirm
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: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy
path: dist/TgWsProxy.exe
- 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: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.version }}
name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: |
## TG WS Proxy ${{ github.event.inputs.version }}
files: dist/TgWsProxy.exe
draft: false
prerelease: false
- name: Build and package
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
package_ipk="${ipk_packages[0]}"
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
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
- name: Upload IPK artifact
uses: actions/upload-artifact@v7
with:
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
- 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: Download build artifacts
uses: actions/download-artifact@v8
with:
path: out
merge-multiple: true
- name: Resolve latest target release
id: target-release
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const picked = releases.find((rel) => !rel.draft && !rel.prerelease);
if (!picked) {
core.setFailed('No release found to update');
return;
}
core.setOutput('id', String(picked.id));
core.setOutput('tag', picked.tag_name);
- name: Remove previous package assets
env:
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 }}"'"}}'

29
.gitignore vendored
View File

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

21
LICENSE
View File

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

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

189
README.md
View File

@ -1,119 +1,124 @@
# TG WS Proxy
# TG WS Proxy Go for embedded devices ([FAQ](https://github.com/Flowseal/tg-ws-proxy/issues/389))
Локальный SOCKS5-прокси для Telegram Desktop, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram.
**Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
## Как это работает
### KeeneticOS
```shell
curl -fsSL https://raw.githubusercontent.com/spatiumstas/feedly/main/add-repo.sh | sh
opkg install tg-ws-proxy
```
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.telegram.org) → Telegram DC
### OpenWRT
Вставьте ссылку на пакет из Releases
> IPK
```shell
opkg install %link%
```
> APK
```shell
wget -O "/etc/apk/keys/tg-ws-proxy.pem" "https://github.com/spatiumstas/tg-ws-proxy-go/releases/latest/download/tg-ws-proxy.pem"
wget -O /tmp/tg-ws-proxy.apk %link%
apk add /tmp/tg-ws-proxy.apk
```
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org`
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
### Конфигурация
## Установка
### Из исходников
```bash
pip install -r requirements.txt
```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
```
## Использование
```conf
# config.conf
HOST=0.0.0.0
PORT=1443
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=""
### Tray-приложение (рекомендуется для Windows)
```bash
python tg_ws_tray.py
# secret.conf
SECRET=
```
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
> Примечания:
**Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
1. `SECRET` должен быть строкой из 32 hex-символов. Если оставить пустым, он будет автоматически сгенерирован при запуске.
2. `DC_IP_DEFAULT` и `DC_IP_DEFAULT_POOL` — глобальные значения по умолчанию для DC (`2,4`).
3. `EXTRA_ARGS` используется для переопределений по DC и дополнительных флагов, см. [CFProxy](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md).
4. Полный список доступных команд: `--help`.
5. `FAKE_TLS_DOMAIN` включает режим Fake TLS (`ee` secret link). Оставьте пустым для стандартного режима `dd`.
6. `CFPROXY_DOMAINS` — локальный список fallback-доменов.
7. `CFPROXY_DOMAINS_URL` — [значение по умолчанию](https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt)/[зеркало](https://raw.githubusercontent.com/spatiumstas/tg-ws-proxy-go/main-go/.github/cfproxy-domains.txt)
### Консольный режим
Примеры переопределений:
```bash
python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
```conf
# Переопределение пула для отдельного DC (DC2)
EXTRA_ARGS="--dc-ip-pool 2:149.154.175.50,149.154.167.220"
# Переопределение одним IP для отдельного DC (DC203) + логи
EXTRA_ARGS="--dc-ip 203:91.105.192.100 -v"
# Режим Fake TLS (ee-secret)
FAKE_TLS_DOMAIN="example.com"
```
**Аргументы:**
### Запуск
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
```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
**Примеры:**
```bash
# Стандартный запуск
python tg_ws_proxy.py
# Другой порт и дополнительные DC
python tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
python tg_ws_proxy.py -v
# OpenWrt/generic OPKG
service tg-ws-proxy start
service tg-ws-proxy status
service tg-ws-proxy restart
service tg-ws-proxy stop
```
## Настройка Telegram Desktop
### Запись логов
### Автоматически
ПКМ по иконке в трее → **«Открыть в Telegram»**
### Вручную
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
## Конфигурация
Tray-приложение хранит конфигурацию в `%APPDATA%/TgWsProxy/config.json`:
```json
{
"port": 1080,
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false
}
```conf
EXTRA_ARGS="-v"
```
Логи записываются в `%APPDATA%/TgWsProxy/proxy.log`.
## Сборка exe
Проект содержит спецификацию PyInstaller ([`tg_ws_proxy.spec`](tg_ws_proxy.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
```bash
pip install pyinstaller
pyinstaller tg_ws_proxy.spec
```shell
# Entware (KeeneticOS): /opt/var/log/tg-ws-proxy.log
# OpenWrt/generic OPKG: /var/log/tg-ws-proxy.log
```
## Дисклеймер
Проект частично vibecoded by Opus 4.6. Если вы найдете баг, то создайте Issue с его описанем.
### Сборка
## Лицензия
```shell
cp config/entware/aarch64-3.10.config .config
make package
```
[MIT License](LICENSE)
```shell
.build/tg-ws-proxy_<version>-1_<platform>_<target>.ipk
```
### Удаление пакета
```shell
opkg remove tg-ws-proxy
apk del tg-ws-proxy
```
### Удаление репозитория
```shell
rm /opt/etc/opkg/feedly.conf
rm /etc/apk/keys/tg-ws-proxy.pem
```

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.9.2

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,9 @@
HOST=0.0.0.0
PORT=1443
DC_IP_DEFAULT=149.154.167.220
DC_IP_DEFAULT_POOL=""
FAKE_TLS_DOMAIN=""
CFPROXY_DOMAINS=""
CFPROXY_DOMAINS_URL="https://raw.githubusercontent.com/spatiumstas/tg-ws-proxy-go/main-go/.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,164 @@
#!/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
if [ -n "$FAKE_TLS_DOMAIN" ]; then
link="$("$PROG" --print-link --host "$link_host" --port "$PORT" --secret "$SECRET" --fake-tls-domain "$FAKE_TLS_DOMAIN")"
else
link="$("$PROG" --print-link --host "$link_host" --port "$PORT" --secret "$SECRET")"
fi
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
set -- --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
set -- "$@" --cfproxy-domains "$CFPROXY_DOMAINS"
fi
if [ -n "$CFPROXY_DOMAINS_URL" ]; then
set -- "$@" --cfproxy-domains-url "$CFPROXY_DOMAINS_URL"
fi
if [ -n "$CFPROXY_WORKER_DOMAINS" ]; then
set -- "$@" --cfproxy-worker-domain "$CFPROXY_WORKER_DOMAINS"
fi
if [ -n "$FAKE_TLS_DOMAIN" ]; then
set -- "$@" --fake-tls-domain "$FAKE_TLS_DOMAIN"
fi
if [ -n "$EXTRA_ARGS" ]; then
set -- "$@" $EXTRA_ARGS
fi
case " $EXTRA_ARGS " in
*" -v "*) set -- "$@" --log-file "$LOGFILE" ;;
esac
"$PROG" "$@" >/dev/null 2>&1 &
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
case " $EXTRA_ARGS " in
*" -v "*) procd_append_param command --log-file "$LOGFILE" ;;
esac
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 procd_running "$NAME" "$NAME.main"; 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

View File

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

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", &Config{}, 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()
}

430
src/cfproxy_domains.go Normal file
View File

@ -0,0 +1,430 @@
package main
import (
"errors"
"fmt"
"io"
"log"
"math/rand"
"net"
"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.Lock()
defer cfg.cfproxyMu.Unlock()
if len(cfg.FallbackCFProxyDomains) == 0 {
return nil
}
now := time.Now()
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 && cfg.cfproxyDomainAvailableLocked(domain, now) {
out = append(out, domain)
break
}
}
}
for _, domain := range shuffledDomains(cfg.FallbackCFProxyDomains) {
if len(out) == cfProxyMaxAttempts {
break
}
if domain != active && cfg.cfproxyDomainAvailableLocked(domain, now) {
out = append(out, domain)
}
}
return out
}
func (cfg *Config) cfproxyDomainAvailableLocked(domain string, now time.Time) bool {
until, failed := cfg.cfproxyFailUntil[domain]
if !failed {
return true
}
if now.Before(until) {
return false
}
delete(cfg.cfproxyFailUntil, domain)
return true
}
func (cfg *Config) markCFProxyDomainFailed(domain string, cooldown time.Duration) bool {
normalized := normalizeCFProxyDomain(domain)
if normalized == "" {
return false
}
cfg.cfproxyMu.Lock()
defer cfg.cfproxyMu.Unlock()
if cfg.cfproxyFailUntil == nil {
cfg.cfproxyFailUntil = make(map[string]time.Time)
}
now := time.Now()
if until, failed := cfg.cfproxyFailUntil[normalized]; failed && now.Before(until) {
return false
}
cfg.cfproxyFailUntil[normalized] = now.Add(cooldown)
return true
}
func cfproxyFailureCooldown(err error) time.Duration {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return cfProxyDNSFailCooldown
}
return cfProxyFailCooldown
}
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.cfproxyFailUntil == nil {
cfg.cfproxyFailUntil = make(map[string]time.Time)
}
for domain := range cfg.cfproxyFailUntil {
found := false
for _, current := range pool {
if domain == current {
found = true
break
}
}
if !found {
delete(cfg.cfproxyFailUntil, domain)
}
}
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
delete(cfg.cfproxyFailUntil, 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)
}
}

133
src/cfproxy_state_test.go Normal file
View File

@ -0,0 +1,133 @@
package main
import (
"testing"
"time"
)
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)
}
}
func TestCFProxyDomainsSkipCooldownAndLimitAttempts(t *testing.T) {
cfg := &Config{}
cfg.setCFProxyDomains([]string{"a.tld", "b.tld", "c.tld", "d.tld"})
cfg.promoteCFProxyDomain(2, "a.tld")
if !cfg.markCFProxyDomainFailed("a.tld", time.Minute) {
t.Fatal("first failure must start cooldown")
}
if cfg.markCFProxyDomainFailed("a.tld", time.Minute) {
t.Fatal("second failure during cooldown must be suppressed")
}
order := cfg.cfproxyDomainsForTry(2)
if len(order) != cfProxyMaxAttempts {
t.Fatalf("cfproxyDomainsForTry returned %d domains, want %d", len(order), cfProxyMaxAttempts)
}
for _, domain := range order {
if domain == "a.tld" {
t.Fatalf("cooled-down domain was returned: %v", order)
}
}
}
func TestCFProxyDomainCooldownExpiresAndSuccessClearsIt(t *testing.T) {
cfg := &Config{}
cfg.setCFProxyDomains([]string{"a.tld", "b.tld"})
if !cfg.markCFProxyDomainFailed("b.tld", time.Minute) {
t.Fatal("first failure must start cooldown")
}
cfg.promoteCFProxyDomain(2, "b.tld")
cfg.cfproxyMu.RLock()
_, stillFailed := cfg.cfproxyFailUntil["b.tld"]
cfg.cfproxyMu.RUnlock()
if stillFailed {
t.Fatal("successful domain must clear cooldown")
}
if !cfg.markCFProxyDomainFailed("a.tld", time.Minute) {
t.Fatal("first failure must start cooldown")
}
cfg.cfproxyMu.Lock()
cfg.cfproxyFailUntil["a.tld"] = time.Now().Add(-time.Second)
cfg.cfproxyMu.Unlock()
order := cfg.cfproxyDomainsForTry(2)
if !containsCFProxyDomain(order, "a.tld") {
t.Fatalf("expired cooldown must allow domain: %v", order)
}
}
func containsCFProxyDomain(domains []string, wanted string) bool {
for _, domain := range domains {
if domain == wanted {
return true
}
}
return false
}

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", 64, "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)
}

133
src/config_test.go Normal file
View File

@ -0,0 +1,133 @@
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.BufKB != 64 {
t.Errorf("buf-kb=%d, want 64", cfg.BufKB)
}
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")
}

80
src/constants.go Normal file
View File

@ -0,0 +1,80 @@
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
ipFailCooldown = time.Hour
dcFailCooldown = 60 * time.Second
frontingCooldown = 30 * time.Minute
dcBlacklistTTL = 10 * time.Minute
ioIdleTimeout = 5 * time.Minute
idleWatchdogCheckInterval = 5 * time.Second
wsWriteTimeout = 15 * time.Second
wsConnectTimeout = 5 * 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
maxSplitterPacketBytes = 8 * 1024 * 1024
acceptPollTimeout = 1 * time.Second
acceptBackoffMin = 5 * time.Millisecond
acceptBackoffMax = 1 * time.Second
defaultMaxConns = 256
defaultCFProxyDomain = "pclead.co.uk"
defaultCFProxyRefreshTimeout = 10 * time.Second
defaultCFProxyRefreshInterval = 1 * time.Hour
defaultCFProxyRefreshMinValidDomains = 3
cfProxyMaxAttempts = 3
cfProxyDNSFailCooldown = 5 * time.Minute
cfProxyFailCooldown = 1 * time.Minute
)
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")

368
src/fake_tls.go Normal file
View File

@ -0,0 +1,368 @@
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 := newUpstreamDialer(tcpDialTimeout).Dial("tcp", net.JoinHostPort(domain, "443"))
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
}
_ = upstream.SetWriteDeadline(time.Time{})
}
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=

60
src/idle_watchdog_test.go Normal file
View File

@ -0,0 +1,60 @@
package main
import (
"sync/atomic"
"testing"
"time"
)
func TestIdleWatchdogKeepsOneWayTrafficAlive(t *testing.T) {
var lastActivity atomic.Int64
lastActivity.Store(time.Now().UnixNano())
stop := make(chan struct{})
fired := make(chan struct{}, 1)
done := make(chan struct{})
go func() {
runIdleWatchdog(&lastActivity, stop, 30*time.Millisecond, 2*time.Millisecond, func() { fired <- struct{}{} })
close(done)
}()
deadline := time.Now().Add(80 * time.Millisecond)
for time.Now().Before(deadline) {
lastActivity.Store(time.Now().UnixNano())
time.Sleep(5 * time.Millisecond)
}
select {
case <-fired:
t.Fatal("watchdog fired despite continuous traffic in one direction")
default:
}
close(stop)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("watchdog did not stop")
}
}
func TestIdleWatchdogClosesIdleSession(t *testing.T) {
var lastActivity atomic.Int64
lastActivity.Store(time.Now().UnixNano())
stop := make(chan struct{})
fired := make(chan struct{}, 1)
done := make(chan struct{})
go func() {
runIdleWatchdog(&lastActivity, stop, 20*time.Millisecond, 2*time.Millisecond, func() { fired <- struct{}{} })
close(done)
}()
select {
case <-fired:
case <-time.After(time.Second):
t.Fatal("watchdog did not close an idle session")
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("watchdog did not finish after closing idle session")
}
}

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

151
src/pool.go Normal file
View File

@ -0,0 +1,151 @@
package main
import (
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
type pooledWS struct {
Conn *websocket.Conn
Created time.Time
}
type wsPoolKey struct {
DC int
IsMedia bool
TargetIP string
}
type wsPool struct {
mu sync.Mutex
idle map[wsPoolKey][]pooledWS
refilling map[wsPoolKey]bool
}
var (
poolWSConnect = wsConnect
poolWSConnectFronting = wsConnectFronting
)
func newWSPool() *wsPool {
return &wsPool{
idle: make(map[wsPoolKey][]pooledWS),
refilling: make(map[wsPoolKey]bool),
}
}
func (p *wsPool) get(cfg *Config, key dcKey, targetIP string, domains []string) *websocket.Conn {
poolKey := wsPoolKey{DC: key.DC, IsMedia: key.IsMedia, TargetIP: targetIP}
now := time.Now()
for {
p.mu.Lock()
bucket := p.idle[poolKey]
if len(bucket) == 0 {
p.scheduleRefill(cfg, poolKey, domains)
p.mu.Unlock()
atomic.AddInt64(&stats.poolMisses, 1)
return nil
}
item := bucket[0]
p.idle[poolKey] = bucket[1:]
p.scheduleRefill(cfg, poolKey, 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 wsPoolKey, domains []string) {
if cfg.PoolSize <= 0 || p.refilling[key] {
return
}
p.refilling[key] = true
go p.refill(cfg, key, domains)
}
func (p *wsPool) refill(cfg *Config, key wsPoolKey, 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
}
if inIPCooldown(key.TargetIP) {
return
}
connect := poolWSConnect
if frontingActive() {
connect = poolWSConnectFronting
}
conn, _, err := connect(key.TargetIP, domains, poolConnectTimeout)
if err != nil {
return
}
p.mu.Lock()
if inIPCooldown(key.TargetIP) {
p.mu.Unlock()
_ = conn.Close()
return
}
p.idle[key] = append(p.idle[key], pooledWS{Conn: conn, Created: time.Now()})
p.mu.Unlock()
}
}
func (p *wsPool) discardTarget(targetIP string) {
if targetIP == "" {
return
}
var stale []pooledWS
p.mu.Lock()
for key, bucket := range p.idle {
if key.TargetIP != targetIP {
continue
}
stale = append(stale, bucket...)
delete(p.idle, key)
}
p.mu.Unlock()
for _, item := range stale {
_ = item.Conn.Close()
}
}
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 := wsPoolKey{DC: dc, IsMedia: media, TargetIP: ip}
domains := wsDomains(dcw, media)
pool.mu.Lock()
pool.scheduleRefill(cfg, key, domains)
pool.mu.Unlock()
}
}
logf("INFO WS pool warmup started for %d DC(s)", len(cfg.DCMap))
}

194
src/pool_test.go Normal file
View File

@ -0,0 +1,194 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"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}
poolKey := wsPoolKey{DC: key.DC, IsMedia: key.IsMedia, TargetIP: "1.2.3.4"}
p.idle[poolKey] = []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}
poolKey := wsPoolKey{DC: key.DC, IsMedia: key.IsMedia, TargetIP: "1.2.3.4"}
p.idle[poolKey] = []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")
}
}
func TestPoolRefillKeepsSizeLimit(t *testing.T) {
origConnect := poolWSConnect
defer func() { poolWSConnect = origConnect }()
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()
}))
defer srv.Close()
url := "ws" + strings.TrimPrefix(srv.URL, "http")
var cleanupsMu sync.Mutex
var cleanups []func()
var calls int64
poolWSConnect = func(string, []string, time.Duration) (*websocket.Conn, *http.Response, error) {
atomic.AddInt64(&calls, 1)
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return nil, nil, err
}
cleanupsMu.Lock()
cleanups = append(cleanups, func() { _ = conn.Close() })
cleanupsMu.Unlock()
return conn, nil, nil
}
defer func() {
cleanupsMu.Lock()
defer cleanupsMu.Unlock()
for _, cleanup := range cleanups {
cleanup()
}
}()
p := newWSPool()
key := wsPoolKey{DC: 1, TargetIP: "1.2.3.4"}
cfg := &Config{PoolSize: 4}
p.refill(cfg, key, []string{"d"})
if got := len(p.idle[key]); got != cfg.PoolSize {
t.Fatalf("pool size = %d, want %d", got, cfg.PoolSize)
}
if got := atomic.LoadInt64(&calls); got != int64(cfg.PoolSize) {
t.Fatalf("connect calls = %d, want %d", got, cfg.PoolSize)
}
}
func TestPoolRefillDialsSequentially(t *testing.T) {
origConnect := poolWSConnect
defer func() { poolWSConnect = origConnect }()
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()
}))
defer srv.Close()
url := "ws" + strings.TrimPrefix(srv.URL, "http")
var cleanupsMu sync.Mutex
var cleanups []func()
var active int64
var maxActive int64
poolWSConnect = func(string, []string, time.Duration) (*websocket.Conn, *http.Response, error) {
cur := atomic.AddInt64(&active, 1)
for {
max := atomic.LoadInt64(&maxActive)
if cur <= max || atomic.CompareAndSwapInt64(&maxActive, max, cur) {
break
}
}
time.Sleep(5 * time.Millisecond)
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
atomic.AddInt64(&active, -1)
if err != nil {
return nil, nil, err
}
cleanupsMu.Lock()
cleanups = append(cleanups, func() { _ = conn.Close() })
cleanupsMu.Unlock()
return conn, nil, nil
}
defer func() {
cleanupsMu.Lock()
defer cleanupsMu.Unlock()
for _, cleanup := range cleanups {
cleanup()
}
}()
p := newWSPool()
key := wsPoolKey{DC: 1, TargetIP: "1.2.3.4"}
cfg := &Config{PoolSize: 4}
p.refill(cfg, key, []string{"d"})
if got := atomic.LoadInt64(&maxActive); got != 1 {
t.Fatalf("max concurrent dials = %d, want 1", got)
}
}
func TestPoolSeparatesTargets(t *testing.T) {
conn, cleanup := dialTestWS(t)
defer cleanup()
p := newWSPool()
key := dcKey{DC: 1}
p.idle[wsPoolKey{DC: 1, TargetIP: "1.2.3.4"}] = []pooledWS{{Conn: conn, Created: time.Now()}}
if got := p.get(&Config{PoolSize: 0}, key, "5.6.7.8", []string{"d"}); got != nil {
t.Fatal("pool must not reuse a connection opened through a different target IP")
}
}
func TestPoolDiscardsTarget(t *testing.T) {
conn, cleanup := dialTestWS(t)
defer cleanup()
p := newWSPool()
p.idle[wsPoolKey{DC: 1, TargetIP: "1.2.3.4"}] = []pooledWS{{Conn: conn, Created: time.Now()}}
p.discardTarget("1.2.3.4")
if len(p.idle) != 0 {
t.Fatal("discardTarget must remove all pooled connections for the target IP")
}
}

424
src/server.go Normal file
View File

@ -0,0 +1,424 @@
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]
directTargets := targets
hasAnyCFFallback := cfg.hasCFProxyWorkerDomains() || (cfg.FallbackCFProxy && cfg.hasCFProxyDomains())
if hasAnyCFFallback {
directTargets = make([]string, 0, len(targets))
for _, target := range targets {
if !inIPCooldown(target) {
directTargets = append(directTargets, target)
}
}
if len(directTargets) == 0 {
log.Printf("INFO [%s] DC%d%s WS target IPs are timed out -> fallback", label, hi.DC, mediaTag)
doFallback(false, false, false, primaryTarget)
return
}
}
wsTarget := directTargets[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, string, bool, bool, bool) {
wsFailedRedirect := false
allRedirect := true
timedOut := false
for _, target := range directTargets {
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, target, wsFailedRedirect, allRedirect, timedOut
}
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
}
if isTimeoutError(err) {
timedOut = true
allRedirect = false
if hasAnyCFFallback {
setIPCooldown(target)
}
warnf("[%s] DC%d%s WS connect timed out via %s", label, hi.DC, mediaTag, target)
break
}
allRedirect = false
warnf("[%s] DC%d%s WS connect failed via %s: %v", label, hi.DC, mediaTag, target, err)
}
}
return nil, "", wsFailedRedirect, allRedirect, timedOut
}
connectFronting := func(reason string) (*websocket.Conn, string) {
for _, target := range directTargets {
log.Printf("INFO [%s] DC%d%s -> fronting %s via %s", label, hi.DC, mediaTag, reason, target)
conn, _, err := wsConnectFronting(target, domains, wsConnectTimeout)
if err == nil {
setFrontingActive()
atomic.AddInt64(&stats.connectionsFront, 1)
log.Printf("INFO [%s] DC%d%s fronting OK for %ds", label, hi.DC, mediaTag, int(frontingCooldown.Seconds()))
return conn, target
}
atomic.AddInt64(&stats.wsErrors, 1)
warnf("[%s] DC%d%s fronting failed via %s: %v", label, hi.DC, mediaTag, target, err)
}
return nil, ""
}
dialFresh := func() (*websocket.Conn, string, bool) {
if frontingActive() {
if conn, target := connectFronting("active"); conn != nil {
return conn, target, false
}
clearFrontingActive()
}
timeout := wsConnectTimeout
if inCooldown(key) {
timeout = wsConnectCooldownTimeout
}
conn, target, wsFailedRedirect, allRedirect, timedOut := connectWS(timeout)
if conn == nil {
if timedOut {
if conn, target := connectFronting("fallback"); conn != nil {
return conn, target, false
}
}
doFallback(true, wsFailedRedirect, allRedirect, primaryTarget)
}
return conn, target, conn != nil
}
ws := pool.get(cfg, key, wsTarget, domains)
usedTarget := wsTarget
directWS := false
fromPool := ws != nil
if fromPool {
log.Printf("INFO [%s] DC%d%s -> pool hit via %s", label, hi.DC, mediaTag, wsTarget)
} else {
ws, usedTarget, directWS = dialFresh()
if ws == nil {
return
}
}
var splitter *msgSplitter
if ms, err := newMsgSplitter(relayInit, protoInt); err == nil {
splitter = ms
}
if err := writeWSBinary(ws, relayInit); err != nil {
warnf("[%s] ws init write failed: %v", label, err)
_ = ws.Close()
if !fromPool {
setCooldown(key)
doFallback(false, false, false, primaryTarget)
return
}
ws, usedTarget, directWS = dialFresh()
if ws == nil {
return
}
if err := writeWSBinary(ws, 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)
if directWS {
clearIPCooldown(usedTarget)
}
atomic.AddInt64(&stats.connectionsWS, 1)
bridgeWS(label, cfg, hi.DC, hi.IsMedia, client, ws, cltDec, cltEnc, tgEnc, tgDec, splitter)
}

140
src/splitter.go Normal file
View File

@ -0,0 +1,140 @@
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}
}
if len(m.cipherBuf)+len(chunk) > maxSplitterPacketBytes {
parts := make([][]byte, 0, 2)
if len(m.cipherBuf) > 0 {
parts = append(parts, m.cipherBuf)
}
parts = append(parts, chunk)
m.cipherBuf = nil
m.plainBuf = nil
m.disabled = true
return parts
}
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 packetLen > maxSplitterPacketBytes {
return 0
}
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 packetLen > maxSplitterPacketBytes {
return 0
}
if len(m.plainBuf) < packetLen {
return -1
}
return packetLen
}

155
src/splitter_test.go Normal file
View File

@ -0,0 +1,155 @@
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 TestSplitterDisablesOversizedPacket(t *testing.T) {
ri := testRelayInit(t)
ms, err := newMsgSplitter(ri, protoIntermediateInt)
if err != nil {
t.Fatal(err)
}
plain := make([]byte, 4)
binary.LittleEndian.PutUint32(plain, maxSplitterPacketBytes)
ct := encForSplitter(t, ri, plain)
parts := ms.split(ct)
if len(parts) != 1 || !bytes.Equal(parts[0], ct) {
t.Fatal("oversized packet header must disable splitter and pass data through")
}
if !ms.disabled || len(ms.cipherBuf) != 0 || len(ms.plainBuf) != 0 {
t.Fatal("oversized packet must not remain buffered")
}
}
func lensOf(parts [][]byte) []int {
out := make([]int, len(parts))
for i, p := range parts {
out[i] = len(p)
}
return out
}

120
src/state.go Normal file
View File

@ -0,0 +1,120 @@
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
ipFailUntil = make(map[string]time.Time)
ipFuMu sync.Mutex
frontingUntil time.Time
frontingMu 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 setIPCooldown(ip string) {
if ip == "" {
return
}
ipFuMu.Lock()
ipFailUntil[ip] = time.Now().Add(ipFailCooldown)
ipFuMu.Unlock()
pool.discardTarget(ip)
}
func inIPCooldown(ip string) bool {
if ip == "" {
return false
}
ipFuMu.Lock()
t, ok := ipFailUntil[ip]
if ok && !time.Now().Before(t) {
delete(ipFailUntil, ip)
ok = false
}
ipFuMu.Unlock()
return ok
}
func clearIPCooldown(ip string) {
if ip == "" {
return
}
ipFuMu.Lock()
delete(ipFailUntil, ip)
ipFuMu.Unlock()
}
func setFrontingActive() {
frontingMu.Lock()
frontingUntil = time.Now().Add(frontingCooldown)
frontingMu.Unlock()
}
func clearFrontingActive() {
frontingMu.Lock()
frontingUntil = time.Time{}
frontingMu.Unlock()
}
func frontingActive() bool {
frontingMu.Lock()
active := time.Now().Before(frontingUntil)
frontingMu.Unlock()
return active
}
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
}

110
src/state_test.go Normal file
View File

@ -0,0 +1,110 @@
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")
}
}
func TestIPCooldown(t *testing.T) {
ip := "149.154.167.51"
clearIPCooldown(ip)
if inIPCooldown(ip) {
t.Fatal("not in IP cooldown after clear")
}
setIPCooldown(ip)
if !inIPCooldown(ip) {
t.Fatal("expected in IP cooldown after set")
}
clearIPCooldown(ip)
if inIPCooldown(ip) {
t.Fatal("expected not in IP cooldown after clear")
}
}
func TestIPCooldownTTL(t *testing.T) {
ip := "149.154.175.50"
setIPCooldown(ip)
if !inIPCooldown(ip) {
t.Fatal("expected in IP cooldown right after set")
}
ipFuMu.Lock()
ipFailUntil[ip] = time.Now().Add(-time.Minute)
ipFuMu.Unlock()
if inIPCooldown(ip) {
t.Fatal("expected expired IP cooldown entry to report false")
}
ipFuMu.Lock()
_, ok := ipFailUntil[ip]
ipFuMu.Unlock()
if ok {
t.Fatal("expired IP cooldown entry should be deleted")
}
}
func TestEmptyIPCooldownNoop(t *testing.T) {
setIPCooldown("")
if inIPCooldown("") {
t.Fatal("empty IP must never be in cooldown")
}
clearIPCooldown("")
}
func TestFrontingActive(t *testing.T) {
clearFrontingActive()
if frontingActive() {
t.Fatal("fronting should be inactive after clear")
}
setFrontingActive()
if !frontingActive() {
t.Fatal("fronting should be active after set")
}
clearFrontingActive()
if frontingActive() {
t.Fatal("fronting should be inactive after second clear")
}
}

471
src/transport.go Normal file
View File

@ -0,0 +1,471 @@
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) {
return dialWSWithSNI(targetIP, domain, domain, timeout)
}
func dialWSFronting(targetIP, domain string, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
return dialWSWithSNI(targetIP, domain, "sprinthost.ru", timeout)
}
func dialWSWithSNI(targetIP, domain, sni 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: sni,
InsecureSkipVerify: true,
},
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return newUpstreamDialer(timeout).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 writeWSBinary(ws *websocket.Conn, data []byte) error {
if err := ws.SetWriteDeadline(time.Now().Add(wsWriteTimeout)); err != nil {
return err
}
err := ws.WriteMessage(websocket.BinaryMessage, data)
if err == nil {
_ = ws.SetWriteDeadline(time.Time{})
}
return err
}
func writeTCPRelayInit(conn net.Conn, relayInit []byte) error {
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteTimeout)); err != nil {
return err
}
_, err := conn.Write(relayInit)
if err == nil {
_ = conn.SetWriteDeadline(time.Time{})
}
return err
}
func runIdleWatchdog(lastActivity *atomic.Int64, stop <-chan struct{}, timeout, checkInterval time.Duration, onIdle func()) {
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
if time.Now().UnixNano()-lastActivity.Load() >= timeout.Nanoseconds() {
onIdle()
return
}
}
}
}
func startIdleWatchdog(lastActivity *atomic.Int64, stop <-chan struct{}, onIdle func()) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
runIdleWatchdog(lastActivity, stop, ioIdleTimeout, idleWatchdogCheckInterval, onIdle)
}()
return done
}
func bridgeWS(label string, cfg *Config, 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)
stopIdleWatchdog := make(chan struct{})
var lastActivity atomic.Int64
lastActivity.Store(time.Now().UnixNano())
idleWatchdogDone := startIdleWatchdog(&lastActivity, stopIdleWatchdog, func() {
_ = ws.Close()
_ = client.Close()
})
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 {
n, err := client.Read(buf)
if n > 0 {
lastActivity.Store(time.Now().UnixNano())
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 {
mt, r, err := ws.NextReader()
if err != nil {
return
}
if mt != websocket.BinaryMessage {
continue
}
downPkts++
for {
nr, rerr := r.Read(buf)
if nr > 0 {
lastActivity.Store(time.Now().UnixNano())
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
close(stopIdleWatchdog)
_ = ws.Close()
_ = client.Close()
<-done
<-idleWatchdogDone
debugf(cfg, "[%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 := newUpstreamDialer(tcpDialTimeout).Dial("tcp", net.JoinHostPort(dst, "443"))
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 := writeTCPRelayInit(r, relayInit); err != nil {
return err
}
done := make(chan struct{}, 2)
stopIdleWatchdog := make(chan struct{})
var lastActivity atomic.Int64
lastActivity.Store(time.Now().UnixNano())
idleWatchdogDone := startIdleWatchdog(&lastActivity, stopIdleWatchdog, func() {
_ = client.Close()
_ = r.Close()
})
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 {
n, err := client.Read(buf)
if n > 0 {
lastActivity.Store(time.Now().UnixNano())
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 {
n, err := r.Read(buf)
if n > 0 {
lastActivity.Store(time.Now().UnixNano())
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
close(stopIdleWatchdog)
_ = client.Close()
_ = r.Close()
<-done
<-idleWatchdogDone
return nil
}
func wsConnect(targetIP string, domains []string, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
return wsConnectWithDialer(targetIP, domains, timeout, dialWS)
}
func wsConnectFronting(targetIP string, domains []string, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
return wsConnectWithDialer(targetIP, domains, timeout, dialWSFronting)
}
func wsConnectWithDialer(targetIP string, domains []string, timeout time.Duration, dial func(string, string, time.Duration) (*websocket.Conn, *http.Response, error)) (*websocket.Conn, *http.Response, error) {
var lastErr error
var lastResp *http.Response
for _, domain := range domains {
conn, resp, err := dial(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,
},
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return newUpstreamDialer(timeout).DialContext(ctx, network, addr)
},
}
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,
},
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return newUpstreamDialer(timeout).DialContext(ctx, network, addr)
},
}
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 := writeWSBinary(ws, 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, cfg, 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)
debugf(cfg, "[%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)
firstFailure := cfg.markCFProxyDomainFailed(baseDomain, cfproxyFailureCooldown(err))
if resp != nil && isRedirect(resp.StatusCode) {
if firstFailure {
warnf("[%s] DC%d%s CF proxy got %d from %s; cooling down", label, dc, mediaTag, resp.StatusCode, domain)
}
} else if firstFailure {
warnf("[%s] DC%d%s CF proxy %s failed: %v", label, dc, mediaTag, domain, err)
}
continue
}
if err := writeWSBinary(ws, relayInit); err != nil {
_ = ws.Close()
if cfg.markCFProxyDomainFailed(baseDomain, cfProxyFailCooldown) {
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, cfg, dc, isMedia, client, ws, cltDec, cltEnc, tgEnc, tgDec, splitter)
return nil
}
return errNoDomains
}

95
src/types.go Normal file
View File

@ -0,0 +1,95 @@
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
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
cfproxyFailUntil map[string]time.Time
}
type Stats struct {
connectionsTotal int64
connectionsActive int64
connectionsWS int64
connectionsTCP int64
connectionsCF int64
connectionsFront 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 front=%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.connectionsFront),
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
}

17
src/types_test.go Normal file
View File

@ -0,0 +1,17 @@
package main
import (
"strings"
"sync/atomic"
"testing"
)
func TestStatsSummaryIncludesFrontingConnections(t *testing.T) {
var s Stats
atomic.AddInt64(&s.connectionsFront, 3)
got := s.summary()
if !strings.Contains(got, "front=3") {
t.Fatalf("summary %q does not include fronting counter", got)
}
}

79
src/utils.go Normal file
View File

@ -0,0 +1,79 @@
package main
import (
"context"
"errors"
"fmt"
"math"
"net"
"os"
"time"
)
var tcpKeepAliveConfig = net.KeepAliveConfig{
Enable: true,
Idle: 10 * time.Second,
Interval: 5 * time.Second,
Count: 3,
}
func newUpstreamDialer(timeout time.Duration) *net.Dialer {
return &net.Dialer{Timeout: timeout, KeepAliveConfig: tcpKeepAliveConfig}
}
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 isTimeoutError(err error) bool {
if err == nil {
return false
}
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout() ||
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, os.ErrDeadlineExceeded)
}
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
}

75
src/utils_test.go Normal file
View File

@ -0,0 +1,75 @@
package main
import (
"context"
"errors"
"os"
"testing"
"time"
)
type testTimeoutError struct{}
func (testTimeoutError) Error() string { return "timeout" }
func (testTimeoutError) Timeout() bool { return true }
func (testTimeoutError) Temporary() bool { return false }
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)
}
}
}
func TestIsTimeoutError(t *testing.T) {
if !isTimeoutError(context.DeadlineExceeded) {
t.Fatal("context deadline should be timeout")
}
if !isTimeoutError(os.ErrDeadlineExceeded) {
t.Fatal("os deadline should be timeout")
}
if !isTimeoutError(testTimeoutError{}) {
t.Fatal("net.Error timeout should be timeout")
}
if isTimeoutError(errors.New("plain error")) {
t.Fatal("plain error should not be timeout")
}
if isTimeoutError(nil) {
t.Fatal("nil should not be timeout")
}
}
func TestNewUpstreamDialerUsesKeepAliveConfig(t *testing.T) {
d := newUpstreamDialer(time.Second)
if d.Timeout != time.Second {
t.Fatalf("timeout = %s, want 1s", d.Timeout)
}
if d.KeepAliveConfig != tcpKeepAliveConfig {
t.Fatalf("keepalive config = %#v, want %#v", d.KeepAliveConfig, tcpKeepAliveConfig)
}
}

View File

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

View File

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

View File

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