4 Commits
0.7.1 ... 0.9

Author SHA1 Message Date
83d27dcc97 Release 0.9 2026-06-25 10:40:51 +03:00
99b844089d Release 0.8.1 2026-06-22 00:10:06 +03:00
ffb2803485 Release 0.8 2026-06-16 19:40:11 +03:00
0a6eab401d 0.7.2 2026-05-11 18:37:22 +03:00
43 changed files with 1573 additions and 214 deletions

View File

@ -40,9 +40,26 @@ jobs:
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:
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
needs: [ prepare, test ]
runs-on: ubuntu-22.04
strategy:
fail-fast: false
@ -60,11 +77,16 @@ jobs:
cache: true
cache-dependency-path: src/go.sum
- name: Install dos2unix
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y dos2unix
wget https://github.com/upx/upx/releases/download/v5.1.1/upx-5.1.1-amd64_linux.tar.xz
tar -xf upx-5.1.1-amd64_linux.tar.xz
sudo mv upx-5.1.1-amd64_linux/upx /usr/local/bin/upx
sudo chmod +x /usr/local/bin/upx
- name: Install apk-tools
if: ${{ startsWith(matrix.config, 'config/openwrt/') }}
run: |

View File

@ -17,6 +17,7 @@ GOOS ?=
GOARCH ?=
GOARM ?=
GOMIPS ?=
GO386 ?=
CGO_ENABLED ?= 0
GO_PROXY_DIR ?= src
@ -71,8 +72,12 @@ clean:
build:
mkdir -p "$(COMPILE_DIR)"
cd "$(GO_PROXY_DIR)" && \
GOOS="$(GOOS)" GOARCH="$(GOARCH)" GOARM="$(GOARM)" GOMIPS="$(GOMIPS)" CGO_ENABLED="$(CGO_ENABLED)" \
go build -trimpath -o "$(abspath $(COMPILE_DIR))/tg-ws-proxy" .
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)"
@ -102,9 +107,11 @@ prepare_files: build
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:
$(MAKE) package_ipk
@if [ "$(PLATFORM)" = "openwrt" ]; then $(MAKE) package_apk; fi
package: package_ipk
ifeq ($(PLATFORM),openwrt)
package: package_apk
endif
package_ipk: prepare_files
mkdir -p "$(BUILDS_DIR)"

View File

@ -82,10 +82,10 @@ FAKE_TLS_DOMAIN="example.com"
```shell
# Entware (KeeneticOS)
/opt/etc/init.d/S61tg-ws-proxy start
/opt/etc/init.d/S61tg-ws-proxy status
/opt/etc/init.d/S61tg-ws-proxy restart
/opt/etc/init.d/S61tg-ws-proxy stop
/opt/etc/init.d/S99tg-ws-proxy start
/opt/etc/init.d/S99tg-ws-proxy status
/opt/etc/init.d/S99tg-ws-proxy restart
/opt/etc/init.d/S99tg-ws-proxy stop
# OpenWrt/generic OPKG
service tg-ws-proxy start

View File

@ -1 +1 @@
0.7.1
0.9

View File

@ -6,4 +6,5 @@ DC_IP_DEFAULT_POOL=""
FAKE_TLS_DOMAIN=""
CFPROXY_DOMAINS=""
CFPROXY_DOMAINS_URL="https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt"
CFPROXY_WORKER_DOMAINS=""
EXTRA_ARGS=""

View File

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

View File

@ -2,12 +2,12 @@
set -e
chmod +x /opt/bin/tg-ws-proxy || true
chmod +x /opt/etc/init.d/S61tg-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/S61tg-ws-proxy
INIT_SCRIPT=/opt/etc/init.d/S99tg-ws-proxy
mkdir -p "$CONFIG_DIR"

View File

@ -4,7 +4,7 @@ set -e
[ "${PKG_UPGRADE}" = "1" ] && exit 0
rm -f /opt/bin/tg-ws-proxy
rm -f /opt/etc/init.d/S61tg-ws-proxy
rm -f /opt/etc/init.d/S99tg-ws-proxy
rm -rf /opt/etc/tg-ws-proxy
rm -f /opt/var/log/tg-ws-proxy.log

View File

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

View File

@ -35,6 +35,7 @@ load_config() {
. "$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
}
@ -51,14 +52,12 @@ print_link() {
[ -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
domain_hex="$(printf '%s' "$FAKE_TLS_DOMAIN" | od -An -tx1 2>/dev/null | tr -d ' \n')"
echo -e "$ansi_blue Connect link: tg://proxy?server=$link_host&port=$PORT&secret=ee$SECRET$domain_hex $ansi_std"
logger "Connect link: tg://proxy?server=$link_host&port=$PORT&secret=ee$SECRET$domain_hex"
else
echo -e "$ansi_blue Connect link: tg://proxy?server=$link_host&port=$PORT&secret=dd$SECRET $ansi_std"
logger "Connect link: tg://proxy?server=$link_host&port=$PORT&secret=dd$SECRET"
fi
fk=""
[ -n "$FAKE_TLS_DOMAIN" ] && fk="--fake-tls-domain $FAKE_TLS_DOMAIN"
link="$("$PROG" --print-link --host "$link_host" --port "$PORT" --secret "$SECRET" $fk)"
echo -e "$ansi_blue Connect link: $link $ansi_std"
logger "Connect link: $link"
}
start() {
@ -83,6 +82,9 @@ start() {
if [ -n "$CFPROXY_DOMAINS_URL" ]; then
cf_args="$cf_args --cfproxy-domains-url $CFPROXY_DOMAINS_URL"
fi
if [ -n "$CFPROXY_WORKER_DOMAINS" ]; then
cf_args="$cf_args --cfproxy-worker-domain $CFPROXY_WORKER_DOMAINS"
fi
if [ -n "$FAKE_TLS_DOMAIN" ]; then
cf_args="$cf_args --fake-tls-domain $FAKE_TLS_DOMAIN"
fi

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

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

View File

@ -29,4 +29,5 @@ if [ -z "${SECRET:-}" ]; then
fi
fi
"$INIT_SCRIPT" enable || true
"$INIT_SCRIPT" restart || true

View File

@ -1,5 +1,6 @@
#!/bin/sh
service tg-ws-proxy stop
/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

@ -1,7 +1,7 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=66
START=99
NAME="tg-ws-proxy"
PROG="/usr/bin/tg-ws-proxy"
@ -32,12 +32,13 @@ load_config() {
[ -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
local link_host br_ip fk link
load_config || return 1
[ -n "$HOST" ] && [ -n "$PORT" ] && [ -n "$SECRET" ] || return 1
@ -48,13 +49,11 @@ print_link() {
[ -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)"
if [ -n "$FAKE_TLS_DOMAIN" ]; then
domain_hex="$(printf '%s' "$FAKE_TLS_DOMAIN" | od -An -tx1 2>/dev/null | tr -d ' \n')"
echo "Connect link: tg://proxy?server=$link_host&port=$PORT&secret=ee$SECRET$domain_hex"
else
echo "Connect link: tg://proxy?server=$link_host&port=$PORT&secret=dd$SECRET"
fi
echo "Connect link: $link"
}
start_service() {
@ -82,6 +81,9 @@ start_service() {
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

View File

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

106
src/bridge_test.go Normal file
View File

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

View File

@ -107,6 +107,43 @@ func isLikelyDomain(domain string) bool {
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") {
@ -168,20 +205,22 @@ func fetchCFProxyDomains(url string, timeout time.Duration) ([]string, error) {
}
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 !isLikelyDomain(domain) {
if !isValidCFProxyDomain(domain) {
continue
}
pool = appendUniqueDomains(pool, domain)
}
if len(pool) == 0 {
return nil, fmt.Errorf("empty domain list from %s", trimmedURL)
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
}
@ -230,6 +269,19 @@ func (cfg *Config) hasCFProxyDomains() bool {
return len(cfg.FallbackCFProxyDomains) > 0
}
func (cfg *Config) hasCFProxyWorkerDomains() bool {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()
return len(cfg.FallbackCFProxyWorkerDomains) > 0
}
func (cfg *Config) cfproxyWorkerDomainsForTry() []string {
cfg.cfproxyMu.RLock()
domains := append([]string(nil), cfg.FallbackCFProxyWorkerDomains...)
cfg.cfproxyMu.RUnlock()
return shuffledDomains(domains)
}
func (cfg *Config) cfproxyDomainsForTry(dc int) []string {
cfg.cfproxyMu.RLock()
defer cfg.cfproxyMu.RUnlock()

View File

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

71
src/cfproxy_state_test.go Normal file
View File

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

View File

@ -6,41 +6,54 @@ import (
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"strconv"
"strings"
)
func parseFlags() (*Config, error) {
host := flag.String("host", "127.0.0.1", "Listen host")
port := flag.Int("port", 1443, "Listen port")
secret := flag.String("secret", "", "MTProto secret (32 hex chars)")
genSecret := flag.Bool("gen-secret", false, "Generate random secret and print it")
verbose := flag.Bool("v", false, "Verbose logs")
logFile := flag.String("log-file", "", "Log file path")
logMaxMB := flag.Float64("log-max-mb", 5, "Max log file size before rotate")
logBackups := flag.Int("log-backups", 0, "Number of rotated backups")
bufKB := flag.Int("buf-kb", 256, "Socket buffer size in KB")
poolSize := flag.Int("pool-size", 4, "WS pool size per DC")
fakeTLSDomain := flag.String("fake-tls-domain", "", "Enable Fake TLS (ee-secret) with masking domain")
cfproxyDomain := flag.String("cfproxy-domain", defaultCFProxyDomain, "Cloudflare-proxied domain for WS fallback")
cfproxyDomains := flag.String("cfproxy-domains", "", "Comma-separated Cloudflare proxy domain pool for WS fallback")
noCfproxy := flag.Bool("no-cfproxy", false, "Disable Cloudflare proxy fallback")
cfproxyPriority := flag.Bool("cfproxy-priority", true, "Try cfproxy before TCP fallback")
noCfproxyDomainRefresh := flag.Bool("no-cfproxy-domain-refresh", false, "Disable periodic CF proxy domain refresh from URL")
cfproxyDomainsURL := flag.String("cfproxy-domains-url", "", "URL to fetch CF proxy domain list from")
maxConns := flag.Int("max-conns", defaultMaxConns, "Max concurrent client sessions")
dcIPDefault := flag.String("dc-ip-default", "149.154.167.220", "Default WS target IP for all implicit DCs when --dc-ip is not provided")
dcIPDefaultPool := flag.String("dc-ip-default-pool", "", "Default WS target IP pool for implicit DCs, comma-separated")
pprofListen := flag.String("pprof-listen", "", "Optional pprof listen address (e.g. 127.0.0.1:6060)")
func parseFlags(args []string) (*Config, error) {
fs := flag.NewFlagSet("tg-ws-proxy", flag.ContinueOnError)
fs.SetOutput(io.Discard)
host := fs.String("host", "127.0.0.1", "Listen host")
port := fs.Int("port", 1443, "Listen port")
secret := fs.String("secret", "", "MTProto secret (32 hex chars)")
genSecret := fs.Bool("gen-secret", false, "Generate random secret and print it")
printLink := fs.Bool("print-link", false, "Print the tg:// connect link and exit")
verbose := fs.Bool("v", false, "Verbose logs")
logFile := fs.String("log-file", "", "Log file path")
logMaxMB := fs.Float64("log-max-mb", 5, "Max log file size before rotate")
logBackups := fs.Int("log-backups", 0, "Number of rotated backups")
bufKB := fs.Int("buf-kb", 256, "Socket buffer size in KB")
poolSize := fs.Int("pool-size", 4, "WS pool size per DC")
fakeTLSDomain := fs.String("fake-tls-domain", "", "Enable Fake TLS (ee-secret) with masking domain")
cfproxyDomain := fs.String("cfproxy-domain", defaultCFProxyDomain, "Cloudflare-proxied domain for WS fallback")
cfproxyDomains := fs.String("cfproxy-domains", "", "Comma-separated Cloudflare proxy domain pool for WS fallback")
cfproxyWorkerDomains := fs.String("cfproxy-worker-domain", "", "Comma-separated Cloudflare Worker domain(s) for WS fallback (e.g. name-1234.user.workers.dev); tried first when set")
noCfproxy := fs.Bool("no-cfproxy", false, "Disable Cloudflare proxy fallback")
cfproxyPriority := fs.Bool("cfproxy-priority", true, "Try cfproxy before TCP fallback")
noCfproxyDomainRefresh := fs.Bool("no-cfproxy-domain-refresh", false, "Disable periodic CF proxy domain refresh from URL")
cfproxyDomainsURL := fs.String("cfproxy-domains-url", "", "URL to fetch CF proxy domain list from")
maxConns := fs.Int("max-conns", defaultMaxConns, "Max concurrent client sessions")
dcIPDefault := fs.String("dc-ip-default", "149.154.167.220", "Default WS target IP for all implicit DCs when --dc-ip is not provided")
dcIPDefaultPool := fs.String("dc-ip-default-pool", "", "Default WS target IP pool for implicit DCs, comma-separated")
pprofListen := fs.String("pprof-listen", "", "Optional pprof listen address (e.g. 127.0.0.1:6060)")
var dcIPs multiFlag
var dcIPPools multiFlag
flag.Var(&dcIPs, "dc-ip", "Target DC IP as DC:IP; repeatable")
flag.Var(&dcIPPools, "dc-ip-pool", "Target pool as DC:IP1,IP2,...; repeatable")
flag.Parse()
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)
@ -114,7 +127,7 @@ func parseFlags() (*Config, error) {
dcMap[dc] = dcPool[dc][0]
}
userDomainProvided := flagProvided("cfproxy-domain")
userDomainProvided := provided["cfproxy-domain"]
userPoolProvided := strings.TrimSpace(*cfproxyDomains) != ""
userDomain := normalizeCFProxyDomain(*cfproxyDomain)
userFixedDomain := userDomainProvided && userDomain != ""
@ -148,31 +161,42 @@ func parseFlags() (*Config, error) {
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,
FakeTLSDomain: normalizedFakeTLSDomain,
DCMap: dcMap,
DCPool: dcPool,
FallbackCFProxy: !*noCfproxy,
FallbackCFProxyPriority: *cfproxyPriority,
FallbackCFProxyDomain: "",
FallbackCFProxyUserDomain: userFixedDomain || userPoolProvided,
FallbackCFProxyRefresh: !*noCfproxyDomainRefresh,
FallbackCFProxyDomainsURL: strings.TrimSpace(*cfproxyDomainsURL),
FallbackCFProxyDomains: nil,
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),
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)
@ -221,13 +245,3 @@ func appendUniqueIP(dst []string, ip string) []string {
}
return append(dst, ip)
}
func flagProvided(name string) bool {
key := "--" + name
for _, arg := range os.Args[1:] {
if arg == key || strings.HasPrefix(arg, key+"=") {
return true
}
}
return false
}

130
src/config_test.go Normal file
View File

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

View File

@ -15,18 +15,28 @@ const (
protoIntermediateInt = 0xEEEEEEEE
protoPaddedIntermediateInt = 0xDDDDDDDD
wsPoolMaxAge = 120 * time.Second
dcFailCooldown = 30 * time.Second
ioIdleTimeout = 90 * time.Second
wsWriteTimeout = 15 * time.Second
statsFlushBytes = 256 * 1024
acceptPollTimeout = 1 * time.Second
acceptBackoffMin = 5 * time.Millisecond
acceptBackoffMax = 1 * time.Second
defaultMaxConns = 1024
defaultCFProxyDomain = "pclead.co.uk"
defaultCFProxyRefreshTimeout = 10 * time.Second
defaultCFProxyRefreshInterval = 1 * time.Hour
wsPoolMaxAge = 120 * time.Second
dcFailCooldown = 30 * time.Second
dcBlacklistTTL = 10 * time.Minute
ioIdleTimeout = 90 * time.Second
wsWriteTimeout = 15 * time.Second
wsConnectTimeout = 10 * time.Second
wsConnectCooldownTimeout = 2 * time.Second
poolConnectTimeout = 8 * time.Second
clientHandshakeTimeout = 10 * time.Second
tcpDialTimeout = 10 * time.Second
fakeTLSWriteTimeout = 5 * time.Second
fakeTLSDrainGrace = 1 * time.Second
statsLogInterval = 60 * time.Second
statsFlushBytes = 256 * 1024
acceptPollTimeout = 1 * time.Second
acceptBackoffMin = 5 * time.Millisecond
acceptBackoffMax = 1 * time.Second
defaultMaxConns = 1024
defaultCFProxyDomain = "pclead.co.uk"
defaultCFProxyRefreshTimeout = 10 * time.Second
defaultCFProxyRefreshInterval = 1 * time.Hour
defaultCFProxyRefreshMinValidDomains = 3
)
var (

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

View File

@ -28,7 +28,7 @@ const (
)
var (
fakeTLSCCSFrame = []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01}
fakeTLSCCSFrame = []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01}
fakeTLSServerHelloTemplate = []byte{
0x16, 0x03, 0x03, 0x00, 0x7a,
0x02, 0x00, 0x00, 0x76,
@ -69,7 +69,7 @@ func fakeTLSConnectLink(host string, port int, secretHex, domain string) string
}
func acceptFakeTLSClient(client net.Conn, secret []byte, maskingDomain string, label string) (net.Conn, []byte, bool) {
_ = client.SetReadDeadline(time.Now().Add(10 * time.Second))
_ = client.SetReadDeadline(time.Now().Add(clientHandshakeTimeout))
first := make([]byte, 1)
if _, err := io.ReadFull(client, first); err != nil {
return nil, nil, false
@ -108,7 +108,7 @@ func acceptFakeTLSClient(client net.Conn, secret []byte, maskingDomain string, l
log.Printf("WARN [%s] Fake TLS server hello build failed: %v", label, err)
return nil, nil, false
}
_ = client.SetWriteDeadline(time.Now().Add(10 * time.Second))
_ = client.SetWriteDeadline(time.Now().Add(clientHandshakeTimeout))
if _, err := client.Write(serverHello); err != nil {
return nil, nil, false
}
@ -116,7 +116,7 @@ func acceptFakeTLSClient(client net.Conn, secret []byte, maskingDomain string, l
wrapped := &fakeTLSConn{raw: client}
hs := make([]byte, handshakeLen)
_ = wrapped.SetReadDeadline(time.Now().Add(10 * time.Second))
_ = wrapped.SetReadDeadline(time.Now().Add(clientHandshakeTimeout))
if _, err := io.ReadFull(wrapped, hs); err != nil {
return nil, nil, false
}
@ -209,14 +209,14 @@ func writeFakeTLSRedirect(client net.Conn, domain string) error {
"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(5 * time.Second))
_ = client.SetWriteDeadline(time.Now().Add(fakeTLSWriteTimeout))
_, err := client.Write([]byte(resp))
_ = client.SetWriteDeadline(time.Time{})
return err
}
func proxyToMaskingDomain(client net.Conn, initial []byte, domain string, label string) {
upstream, err := net.DialTimeout("tcp", net.JoinHostPort(domain, "443"), 10*time.Second)
upstream, err := net.DialTimeout("tcp", net.JoinHostPort(domain, "443"), tcpDialTimeout)
if err != nil {
log.Printf("INFO [%s] masking connect failed: %v", label, err)
return
@ -225,7 +225,7 @@ func proxyToMaskingDomain(client net.Conn, initial []byte, domain string, label
log.Printf("INFO [%s] masking -> %s:443", label, domain)
if len(initial) > 0 {
_ = upstream.SetWriteDeadline(time.Now().Add(5 * time.Second))
_ = upstream.SetWriteDeadline(time.Now().Add(fakeTLSWriteTimeout))
if _, err := upstream.Write(initial); err != nil {
return
}
@ -247,7 +247,7 @@ func proxyToMaskingDomain(client net.Conn, initial []byte, domain string, label
_ = upstream.Close()
select {
case <-done:
case <-time.After(1 * time.Second):
case <-time.After(fakeTLSDrainGrace):
}
}

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

View File

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

View File

@ -90,7 +90,7 @@ func debugf(cfg *Config, format string, args ...any) {
}
func warnf(format string, args ...any) {
log.Printf("WARNING "+format, args...)
log.Printf("WARN "+format, args...)
}
func logf(format string, args ...any) {

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

View File

@ -26,28 +26,29 @@ func newWSPool() *wsPool {
}
}
func (p *wsPool) get(cfg *Config, key dcKey, targetIP string, domains []string, st *Stats) *websocket.Conn {
func (p *wsPool) get(cfg *Config, key dcKey, targetIP string, domains []string) *websocket.Conn {
now := time.Now()
p.mu.Lock()
bucket := p.idle[key]
for len(bucket) > 0 {
for {
p.mu.Lock()
bucket := p.idle[key]
if len(bucket) == 0 {
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
atomic.AddInt64(&stats.poolMisses, 1)
return nil
}
item := bucket[0]
bucket = bucket[1:]
p.idle[key] = bucket[1:]
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
if now.Sub(item.Created) > wsPoolMaxAge {
_ = item.Conn.Close()
continue
}
p.idle[key] = bucket
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
atomic.AddInt64(&st.poolHits, 1)
atomic.AddInt64(&stats.poolHits, 1)
return item.Conn
}
p.idle[key] = bucket
p.scheduleRefill(cfg, key, targetIP, domains)
p.mu.Unlock()
atomic.AddInt64(&st.poolMisses, 1)
return nil
}
func (p *wsPool) scheduleRefill(cfg *Config, key dcKey, targetIP string, domains []string) {
@ -72,7 +73,7 @@ func (p *wsPool) refill(cfg *Config, key dcKey, targetIP string, domains []strin
if cur >= cfg.PoolSize {
return
}
conn, _, err := wsConnect(targetIP, domains, 8*time.Second)
conn, _, err := wsConnect(targetIP, domains, poolConnectTimeout)
if err != nil {
return
}

59
src/pool_test.go Normal file
View File

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

View File

@ -6,6 +6,7 @@ import (
"io"
"log"
"net"
"os"
"strconv"
"strings"
"sync/atomic"
@ -15,7 +16,7 @@ import (
)
func main() {
cfg, err := parseFlags()
cfg, err := parseFlags(os.Args[1:])
if err != nil {
log.Fatalf("config error: %v", err)
}
@ -23,6 +24,15 @@ func main() {
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)
@ -52,6 +62,9 @@ func main() {
}
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 != "" {
@ -63,7 +76,7 @@ func main() {
go func() {
for {
time.Sleep(60 * time.Second)
time.Sleep(statsLogInterval)
log.Printf("INFO stats: %s", stats.summary())
}
}()
@ -107,6 +120,12 @@ func main() {
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:
@ -141,7 +160,7 @@ func handleClient(client net.Conn, cfg *Config, secret []byte) {
return
}
_ = handshakeConn.SetReadDeadline(time.Now().Add(10 * time.Second))
_ = 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)
@ -159,7 +178,6 @@ func handleClient(client net.Conn, cfg *Config, secret []byte) {
}
func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret []byte, label string) {
protoInt := protoFromTag(hi.ProtoTag)
mediaTag := ""
if hi.IsMedia {
@ -197,6 +215,16 @@ func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret
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()
@ -219,6 +247,10 @@ func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret
return false
}
if useWorker && tryWorker() {
return
}
if useCF && cfg.FallbackCFProxyPriority {
if tryCF() || tryTCP() {
return
@ -260,7 +292,7 @@ func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret
for _, target := range targets {
for _, d := range domains {
debugf(cfg, "[%s] DC%d%s -> wss://%s/apiws via %s", label, hi.DC, mediaTag, d, target)
conn, resp, err := wsConnect(target, []string{d}, timeout)
conn, resp, err := dialWS(target, d, timeout)
if err == nil {
allRedirect = false
return conn, wsFailedRedirect, allRedirect
@ -278,27 +310,24 @@ func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret
return nil, wsFailedRedirect, allRedirect
}
var ws *websocket.Conn
fromPool := false
if pooled := pool.get(cfg, key, primaryTarget, domains, &stats); pooled != nil {
ws = pooled
fromPool = true
log.Printf("INFO [%s] DC%d%s -> pool hit via %s", label, hi.DC, mediaTag, primaryTarget)
dialFresh := func() *websocket.Conn {
timeout := wsConnectTimeout
if inCooldown(key) {
timeout = wsConnectCooldownTimeout
}
conn, wsFailedRedirect, allRedirect := connectWS(timeout)
if conn == nil {
doFallback(true, wsFailedRedirect, allRedirect, primaryTarget)
}
return conn
}
if ws == nil {
timeout := 10 * time.Second
if inCooldown(key) {
timeout = 2 * time.Second
}
wsFailedRedirect := false
allRedirect := true
ws, wsFailedRedirect, allRedirect = connectWS(timeout)
if ws == nil {
doFallback(true, wsFailedRedirect, allRedirect, primaryTarget)
return
}
ws := pool.get(cfg, key, primaryTarget, domains)
fromPool := ws != nil
if fromPool {
log.Printf("INFO [%s] DC%d%s -> pool hit via %s", label, hi.DC, mediaTag, primaryTarget)
} else if ws = dialFresh(); ws == nil {
return
}
var splitter *msgSplitter
@ -314,16 +343,7 @@ func handleMTProtoClient(client net.Conn, cfg *Config, hi *handshakeInfo, secret
doFallback(false, false, false, primaryTarget)
return
}
timeout := 10 * time.Second
if inCooldown(key) {
timeout = 2 * time.Second
}
wsFailedRedirect := false
allRedirect := true
ws, wsFailedRedirect, allRedirect = connectWS(timeout)
if ws == nil {
doFallback(true, wsFailedRedirect, allRedirect, primaryTarget)
if ws = dialFresh(); ws == nil {
return
}
if err := ws.WriteMessage(websocket.BinaryMessage, relayInit); err != nil {

136
src/splitter_test.go Normal file
View File

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

View File

@ -9,7 +9,7 @@ import (
var (
stats Stats
pool = newWSPool()
blacklist = make(map[dcKey]struct{})
blacklist = make(map[dcKey]time.Time)
blMu sync.Mutex
failUntil = make(map[dcKey]time.Time)
fuMu sync.Mutex
@ -36,13 +36,18 @@ func clearCooldown(k dcKey) {
func setBlacklisted(k dcKey) {
blMu.Lock()
blacklist[k] = struct{}{}
blacklist[k] = time.Now().Add(dcBlacklistTTL)
blMu.Unlock()
}
func isBlacklisted(dc int, media bool) bool {
k := dcKey{DC: dc, IsMedia: media}
blMu.Lock()
_, ok := blacklist[dcKey{DC: dc, IsMedia: media}]
t, ok := blacklist[k]
if ok && !time.Now().Before(t) {
delete(blacklist, k)
ok = false
}
blMu.Unlock()
return ok
}

49
src/state_test.go Normal file
View File

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

View File

@ -5,9 +5,11 @@ import (
"crypto/cipher"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"sync"
"sync/atomic"
"time"
@ -31,7 +33,7 @@ func dialWS(targetIP, domain string, timeout time.Duration) (*websocket.Conn, *h
InsecureSkipVerify: true,
},
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
d := &net.Dialer{Timeout: timeout}
d := &net.Dialer{Timeout: timeout, KeepAliveConfig: tcpKeepAliveConfig}
return d.DialContext(ctx, "tcp", net.JoinHostPort(targetIP, "443"))
},
}
@ -109,6 +111,8 @@ func bridgeWS(label string, dc int, isMedia bool, client net.Conn, ws *websocket
go func() {
defer func() { done <- struct{}{} }()
buf := ioBufPool.Get().([]byte)
defer ioBufPool.Put(buf)
var downPending int64
defer func() {
if downPending > 0 {
@ -117,27 +121,38 @@ func bridgeWS(label string, dc int, isMedia bool, client net.Conn, ws *websocket
}()
for {
_ = ws.SetReadDeadline(time.Now().Add(ioIdleTimeout))
mt, data, err := ws.ReadMessage()
mt, r, err := ws.NextReader()
if err != nil {
return
}
if mt != websocket.BinaryMessage {
continue
}
n := int64(len(data))
downPending += n
downBytes += n
downPkts++
if downPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesDown, downPending)
downPending = 0
}
tgDec.XORKeyStream(data, data)
cltEnc.XORKeyStream(data, data)
_ = client.SetWriteDeadline(time.Now().Add(ioIdleTimeout))
if _, werr := client.Write(data); werr != nil {
return
for {
nr, rerr := r.Read(buf)
if nr > 0 {
n := int64(nr)
downPending += n
downBytes += n
if downPending >= statsFlushBytes {
atomic.AddInt64(&stats.bytesDown, downPending)
downPending = 0
}
chunk := buf[:nr]
tgDec.XORKeyStream(chunk, chunk)
cltEnc.XORKeyStream(chunk, chunk)
_ = client.SetWriteDeadline(time.Now().Add(ioIdleTimeout))
if _, werr := client.Write(chunk); werr != nil {
return
}
}
if rerr != nil {
if rerr == io.EOF {
break
}
return
}
}
}
}()
@ -158,9 +173,9 @@ func bridgeWS(label string, dc int, isMedia bool, client net.Conn, ws *websocket
}
func tcpFallback(client net.Conn, dst string, relayInit []byte, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) error {
r, err := net.DialTimeout("tcp", net.JoinHostPort(dst, "443"), 10*time.Second)
r, err := net.DialTimeout("tcp", net.JoinHostPort(dst, "443"), tcpDialTimeout)
if err != nil {
logf("WARNING TCP fallback to %s:443 failed: %v", dst, err)
warnf("TCP fallback to %s:443 failed: %v", dst, err)
return err
}
defer r.Close()
@ -276,6 +291,57 @@ func dialWSByDomain(domain string, timeout time.Duration) (*websocket.Conn, *htt
return dialer.Dial(u.String(), headers)
}
func dialWSWorker(worker, dst string, dc int, timeout time.Duration) (*websocket.Conn, *http.Response, error) {
q := url.Values{}
q.Set("dst", dst)
q.Set("dc", strconv.Itoa(dc))
u := url.URL{Scheme: "wss", Host: worker, Path: "/apiws", RawQuery: q.Encode()}
dialer := websocket.Dialer{
HandshakeTimeout: timeout,
Subprotocols: []string{"binary"},
TLSClientConfig: &tls.Config{
ServerName: worker,
InsecureSkipVerify: true,
},
}
headers := http.Header{}
headers.Set("Host", worker)
headers.Set("Origin", "https://web.telegram.org")
return dialer.Dial(u.String(), headers)
}
func cfWorkerFallback(label string, cfg *Config, dc int, isMedia bool, dst string, client net.Conn, relayInit []byte, cltDec, cltEnc, tgEnc, tgDec cipher.Stream, splitter *msgSplitter) error {
mediaTag := ""
if isMedia {
mediaTag = " media"
}
if dst == "" {
return errNoDomains
}
for _, worker := range cfg.cfproxyWorkerDomainsForTry() {
logf("INFO [%s] DC%d%s -> CF worker wss://%s/apiws?dst=%s", label, dc, mediaTag, worker, dst)
ws, _, err := dialWSWorker(worker, dst, dc, wsConnectTimeout)
if err != nil {
atomic.AddInt64(&stats.wsErrors, 1)
warnf("[%s] DC%d%s CF worker %s failed: %v", label, dc, mediaTag, worker, err)
continue
}
if err := ws.WriteMessage(websocket.BinaryMessage, relayInit); err != nil {
_ = ws.Close()
warnf("[%s] DC%d%s CF worker init write failed: %v", label, dc, mediaTag, err)
continue
}
atomic.AddInt64(&stats.connectionsCF, 1)
bridgeWS(label, dc, isMedia, client, ws, cltDec, cltEnc, tgEnc, tgDec, splitter)
return nil
}
return errNoDomains
}
func cfproxyFallback(label string, cfg *Config, dc int, isMedia bool, client net.Conn, relayInit []byte, cltDec, cltEnc, tgEnc, tgDec cipher.Stream, splitter *msgSplitter) error {
mediaTag := ""
if isMedia {
@ -285,7 +351,7 @@ func cfproxyFallback(label string, cfg *Config, dc int, isMedia bool, client net
for _, baseDomain := range cfg.cfproxyDomainsForTry(dc) {
domain := fmt.Sprintf("kws%d.%s", dc, baseDomain)
logf("INFO [%s] DC%d%s -> CF proxy wss://%s/apiws", label, dc, mediaTag, domain)
ws, resp, err := dialWSByDomain(domain, 10*time.Second)
ws, resp, err := dialWSByDomain(domain, wsConnectTimeout)
if err != nil {
atomic.AddInt64(&stats.wsErrors, 1)
if resp != nil && isRedirect(resp.StatusCode) {

View File

@ -7,31 +7,33 @@ import (
)
type Config struct {
Host string
Port int
SecretHex string
GenSecret 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
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
Host string
Port int
SecretHex string
GenSecret bool
PrintLink bool
FakeTLSDomain string
DCMap map[int]string
DCPool map[int][]string
FallbackCFProxy bool
FallbackCFProxyPriority bool
FallbackCFProxyDomain string
FallbackCFProxyUserDomain bool
FallbackCFProxyRefresh bool
FallbackCFProxyDomainsURL string
FallbackCFProxyDomains []string
FallbackCFProxyWorkerDomains []string
FallbackCFProxyActive string
FallbackCFProxyPerDCActive map[int]string
Verbose bool
BufKB int
PoolSize int
MaxConns int
LogFile string
LogMaxMB float64
LogBackups int
PprofListen string
cfproxyMu sync.RWMutex
}
type Stats struct {

View File

@ -4,8 +4,16 @@ import (
"fmt"
"math"
"net"
"time"
)
var tcpKeepAliveConfig = net.KeepAliveConfig{
Enable: true,
Idle: 10 * time.Second,
Interval: 5 * time.Second,
Count: 3,
}
func formatFloat(v float64) string {
return fmt.Sprintf("%.1f", v)
}
@ -49,5 +57,6 @@ func setSockOpts(c net.Conn, bufSize int) error {
_ = tcp.SetNoDelay(true)
_ = tcp.SetReadBuffer(bufSize)
_ = tcp.SetWriteBuffer(bufSize)
_ = tcp.SetKeepAliveConfig(tcpKeepAliveConfig)
return nil
}

35
src/utils_test.go Normal file
View File

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