init
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.cursor
|
||||||
|
*.exe
|
||||||
|
mtproxy_checker
|
||||||
|
README.md
|
||||||
|
docs
|
||||||
|
.gitea
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
name: Publish mtproxy_checker Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.22"
|
||||||
|
REGISTRY: git.shts.su
|
||||||
|
IMAGE_REPO: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go mod tidy && go test ./...
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: |
|
||||||
|
image=moby/buildkit:v0.13.2
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
- name: Prepare Docker tags
|
||||||
|
id: prep
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
SHA: ${{ gitea.sha }}
|
||||||
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
IMAGE="${REGISTRY}/${REPO}"
|
||||||
|
BRANCH_OR_TAG="${REF_NAME:-unknown}"
|
||||||
|
SAFE_REF="$(echo "${BRANCH_OR_TAG}" | tr '/' '-')"
|
||||||
|
SHORT_SHA="$(echo "${SHA}" | cut -c1-12)"
|
||||||
|
|
||||||
|
TAGS="$(printf '%s\n%s\n%s\n' \
|
||||||
|
"${IMAGE}:latest" \
|
||||||
|
"${IMAGE}:${SAFE_REF}" \
|
||||||
|
"${IMAGE}:sha-${SHORT_SHA}")"
|
||||||
|
|
||||||
|
echo "tags<<EOF" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "${TAGS}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "EOF" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
tags: ${{ steps.prep.outputs.tags }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=mtproxy_checker
|
||||||
|
org.opencontainers.image.description=CLI probe for MTProxy (ee Fake-TLS + dd), Telegram-style ClientHello
|
||||||
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
|
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/mtproxy_checker
|
||||||
|
/mtproxy_checker.exe
|
||||||
|
*.exe
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache ca-certificates git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mtproxy_checker ./cmd/mtproxy_checker
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=build /mtproxy_checker /usr/local/bin/mtproxy_checker
|
||||||
|
ENTRYPOINT ["/usr/local/bin/mtproxy_checker"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# mtproxy_checker
|
||||||
|
|
||||||
|
Проверка **MTProto MTProxy** на Go: не ICMP ping, а TCP + протокол (для `ee` — Fake-TLS, затем первый кадр MTProxy; для `dd` — сразу MTProxy Randomized Intermediate).
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go build -o mtproxy_checker.exe ./cmd/mtproxy_checker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mtproxy_checker.exe "tg://proxy?server=HOST&port=PORT&secret=HEX"
|
||||||
|
```
|
||||||
|
|
||||||
|
или
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mtproxy_checker.exe --server HOST --port PORT --secret HEX
|
||||||
|
```
|
||||||
|
|
||||||
|
Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2).
|
||||||
|
|
||||||
|
Код выхода: `0` — OK, `1` — ошибка, `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут.
|
||||||
|
|
||||||
|
**Docker (описание образа, Linux, несколько прокси подряд):** [docs/docker.ru.md](docs/docker.ru.md).
|
||||||
|
|
||||||
|
### Если в Telegram прокси «Available», а утилита падает на `read server hello`
|
||||||
|
|
||||||
|
Для `ee` в TLS SNI должен идти **только ASCII hostname** из хвоста секрета; в коде это `SNIDomain`. Ответ сервера читается по схеме из Telegram Desktop (`mtproto_tls_socket`), а не по старому фиксированному формату telethon.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mtproxy_checker/internal/checker"
|
||||||
|
"mtproxy_checker/internal/parseurl"
|
||||||
|
"mtproxy_checker/internal/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run())
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() int {
|
||||||
|
timeout := flag.Duration("timeout", 15*time.Second, "overall TCP/handshake timeout")
|
||||||
|
dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header")
|
||||||
|
server := flag.String("server", "", "proxy hostname (if not using tg:// positional)")
|
||||||
|
portFlag := flag.Int("port", 0, "proxy port (if not using tg:// positional)")
|
||||||
|
secFlag := flag.String("secret", "", "hex secret (if not using tg:// positional)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
args := flag.Args()
|
||||||
|
var host string
|
||||||
|
var port int
|
||||||
|
var secStr string
|
||||||
|
|
||||||
|
if len(args) >= 1 && (hasPrefix(args[0], "tg://") || hasPrefix(args[0], "TG://")) {
|
||||||
|
t, err := parseurl.ParseTGProxy(args[0])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "parse tg url: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
host, port, secStr = t.Host, t.Port, t.Secret
|
||||||
|
} else {
|
||||||
|
if *server == "" || *portFlag == 0 || *secFlag == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: %s [flags] tg://proxy?server=HOST&port=PORT&secret=HEX\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " or: %s --server HOST --port PORT --secret HEX\n", os.Args[0])
|
||||||
|
flag.PrintDefaults()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
host, port, secStr = *server, *portFlag, *secFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := secret.Parse(secStr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "secret: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if *dcID < -32768 || *dcID > 32767 {
|
||||||
|
fmt.Fprintf(os.Stderr, "dc-id out of int16 range\n")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = checker.Check(ctx, host, port, parsed, int16(*dcID))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, checker.ErrProxyClosed) {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: timeout\n")
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Println("OK")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPrefix(s, p string) bool {
|
||||||
|
return len(s) >= len(p) && s[:len(p)] == p
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# Docker: образ mtproxy_checker
|
||||||
|
|
||||||
|
## Что внутри образа
|
||||||
|
|
||||||
|
| Компонент | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| **База runtime** | Alpine Linux 3.20 |
|
||||||
|
| **Бинарник** | Статическая сборка Go (`CGO_ENABLED=0`), путь в контейнере: `/usr/local/bin/mtproxy_checker` |
|
||||||
|
| **Сертификаты** | Пакет `ca-certificates` (для TLS к реальным хостам при необходимости) |
|
||||||
|
| **ENTRYPOINT** | Тот же бинарник — все аргументы `docker run …` передаются в утилиту |
|
||||||
|
| **Порты** | Ничего не слушает: только **исходящие** TCP до хоста и порта из ссылки |
|
||||||
|
|
||||||
|
Одна команда `docker run` = **одна** проверка одного прокси; процесс завершается с **кодом выхода** (`0`…`4`), что удобно в CI и скриптах.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- [Docker Engine](https://docs.docker.com/engine/install/) (Linux) или Docker Desktop (Windows/macOS).
|
||||||
|
- Для `docker pull` из приватного registry — логин и токен.
|
||||||
|
|
||||||
|
## Сборка образа локально
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/mtproxy_checker
|
||||||
|
docker build -t mtproxy_checker:local .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\path\to\mtproxy_checker
|
||||||
|
docker build -t mtproxy_checker:local .
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка, что образ собран:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm mtproxy_checker:local -timeout 5s 2>&1 | head -1
|
||||||
|
# ожидается сообщение usage (код выхода 2 — нормально без URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск под Linux
|
||||||
|
|
||||||
|
### Один прокси (ссылка `tg://`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm mtproxy_checker:local \
|
||||||
|
'tg://proxy?server=example.com&port=443&secret=eeYOURHEX...'
|
||||||
|
```
|
||||||
|
|
||||||
|
С флагами утилиты (таймаут, DC):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm mtproxy_checker:local \
|
||||||
|
-timeout 25s -dc-id 2 \
|
||||||
|
'tg://proxy?server=example.com&port=443&secret=eeYOURHEX...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы не светить секрет в истории shell, прочитайте его из файла и передайте флагами (образ запускает бинарник как `ENTRYPOINT`, отдельный `sh` без смены entrypoint использовать нельзя):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SECRET=$(tr -d ' \n' < secret.hex)
|
||||||
|
docker run --rm mtproxy_checker:local \
|
||||||
|
--server example.com --port 443 --secret "$SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сеть и DNS на Linux
|
||||||
|
|
||||||
|
- По умолчанию контейнер использует bridge Docker; нужен исходящий доступ к `server:port` и рабочий DNS.
|
||||||
|
- Если имена не резолвятся:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --dns 8.8.8.8 mtproxy_checker:local 'tg://proxy?...'
|
||||||
|
```
|
||||||
|
|
||||||
|
- Режим сети хоста (как на машине без NAT; используйте осознанно):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network host mtproxy_checker:local 'tg://proxy?...'
|
||||||
|
```
|
||||||
|
|
||||||
|
На Docker Desktop для Windows/macOS `--network host` ведёт себя иначе, чем на Linux.
|
||||||
|
|
||||||
|
## Несколько MTProxy ссылок подряд
|
||||||
|
|
||||||
|
Утилита принимает **ровно один** прокси за запуск. Чтобы проверить много `tg://` ссылок, обходите их в оболочке.
|
||||||
|
|
||||||
|
### Вариант 1: цикл в bash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE=mtproxy_checker:local # или registry/owner/mtproxy_checker:latest
|
||||||
|
|
||||||
|
for url in \
|
||||||
|
'tg://proxy?server=a.example.com&port=443&secret=ee1111...' \
|
||||||
|
'tg://proxy?server=b.example.com&port=8443&secret=ee2222...' \
|
||||||
|
'tg://proxy?server=c.example.com&port=443&secret=dd3333...'
|
||||||
|
do
|
||||||
|
echo "=== $url ==="
|
||||||
|
if docker run --rm "$IMAGE" -timeout 20s "$url"; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "FAIL (код $?)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: файл — одна ссылка на строку
|
||||||
|
|
||||||
|
`proxies.txt` (пустые строки и строки с `#` в начале можно пропускать):
|
||||||
|
|
||||||
|
```
|
||||||
|
tg://proxy?server=one.example.com&port=443&secret=ee...
|
||||||
|
tg://proxy?server=two.example.com&port=8443&secret=ee...
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE=mtproxy_checker:local
|
||||||
|
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}" # trim leading spaces
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
case "$line" in \#*) continue ;; esac
|
||||||
|
|
||||||
|
echo "=== $line ==="
|
||||||
|
docker run --rm "$IMAGE" -timeout 20s "$line" && echo OK || echo "FAIL ($?)"
|
||||||
|
done < proxies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 3: параллельно (`xargs`)
|
||||||
|
|
||||||
|
Осторожно: одновременно много контейнеров нагружают сеть и целевые прокси.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE=mtproxy_checker:local
|
||||||
|
|
||||||
|
grep -v '^\s*$\|^\s*#' proxies.txt | xargs -I{} -P 4 sh -c \
|
||||||
|
'echo "=== {}"; docker run --rm '"$IMAGE"' -timeout 20s "{}" || echo FAIL'
|
||||||
|
```
|
||||||
|
|
||||||
|
`-P 4` — не более четырёх проверок одновременно; уменьшите при необходимости.
|
||||||
|
|
||||||
|
### Вариант 4: итоговый код для CI
|
||||||
|
|
||||||
|
Если нужен «провал всего job при любом FAIL»:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
IMAGE=mtproxy_checker:local
|
||||||
|
while IFS= read -r url; do
|
||||||
|
[ -z "$url" ] && continue
|
||||||
|
case "$url" in \#*) continue ;; esac
|
||||||
|
docker run --rm "$IMAGE" -timeout 25s "$url"
|
||||||
|
done < proxies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск из PowerShell (Windows)
|
||||||
|
|
||||||
|
Одинарные кавычки с `tg://` в PowerShell неудобны; проще двойные:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker run --rm mtproxy_checker:local `
|
||||||
|
"tg://proxy?server=example.com&port=443&secret=eeYOURHEX..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Несколько ссылок:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$urls = @(
|
||||||
|
"tg://proxy?server=a.com&port=443&secret=ee...",
|
||||||
|
"tg://proxy?server=b.com&port=8443&secret=ee..."
|
||||||
|
)
|
||||||
|
foreach ($u in $urls) {
|
||||||
|
Write-Host "=== $u ==="
|
||||||
|
docker run --rm mtproxy_checker:local -timeout 20s $u
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "FAIL exit $LASTEXITCODE" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Загрузка образа из Container Registry Gitea
|
||||||
|
|
||||||
|
1. Включите **Packages / Container Registry** на инстансе Gitea ([документация](https://docs.gitea.com/usage/packages/container)).
|
||||||
|
2. Создайте токен с правом на пакеты.
|
||||||
|
3. Логин:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.shts.su
|
||||||
|
# или ваш registry
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Сборка workflow публикует теги `latest`, имя ветки/тега и `sha-…` — см. `.gitea/workflows/docker.yaml`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.shts.su/owner/mtproxy_checker:latest
|
||||||
|
docker run --rm git.shts.su/owner/mtproxy_checker:latest 'tg://proxy?...'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример docker-compose
|
||||||
|
|
||||||
|
Одна проверка за `compose run`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mtproxy-check:
|
||||||
|
image: mtproxy_checker:local
|
||||||
|
build: .
|
||||||
|
command: ['tg://proxy?server=example.com&port=443&secret=ee...']
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm mtproxy-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Для списка прокси удобнее внешний bash-цикл или отдельный `command: ["sh","-c","..."]` с циклом по файлу, смонтированному в volume.
|
||||||
|
|
||||||
|
## Использование в CI другого проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm registry.example.com/owner/mtproxy_checker:latest \
|
||||||
|
"tg://proxy?server=${MTPROXY_HOST}&port=${MTPROXY_PORT}&secret=${MTPROXY_SECRET}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Коды выхода и диагностика
|
||||||
|
|
||||||
|
| Код | Значение |
|
||||||
|
|-----|----------|
|
||||||
|
| 0 | Проверка прошла |
|
||||||
|
| 1 | Ошибка (сеть, протокол, неверный ответ) |
|
||||||
|
| 2 | Неверные аргументы CLI |
|
||||||
|
| 3 | Прокси закрыл TCP сразу после начального payload |
|
||||||
|
| 4 | Общий таймаут (`-timeout`) |
|
||||||
|
|
||||||
|
**Не путать с ping:** ICMP не используется.
|
||||||
|
|
||||||
|
Типичные сообщения:
|
||||||
|
|
||||||
|
- `connection refused` — порт закрыт или фильтр.
|
||||||
|
- `FAIL: timeout` — нет ответа за `-timeout`.
|
||||||
|
- Ошибки чтения/проверки ServerHello — несовместимый ответ или обрыв соединения.
|
||||||
|
|
||||||
|
Для отладки без `--rm` можно посмотреть логи контейнера по id; с `--rm` контейнер удаляется сразу после выхода.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module mtproxy_checker
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require golang.org/x/crypto v0.31.0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mtproxy_checker/internal/faketls"
|
||||||
|
"mtproxy_checker/internal/mtproxy"
|
||||||
|
"mtproxy_checker/internal/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrProxyClosed indicates the MTProxy dropped the TCP connection right after the probe (Telethon #1134 style).
|
||||||
|
var ErrProxyClosed = errors.New("mtproxy closed connection after initial payload")
|
||||||
|
|
||||||
|
// Check runs the handshake probe for the given parsed secret.
|
||||||
|
func Check(ctx context.Context, host string, port int, parsed *secret.Parsed, dcID int16) error {
|
||||||
|
conn, err := dialTCP(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tcp dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
switch parsed.Kind {
|
||||||
|
case secret.KindEE:
|
||||||
|
return checkEE(conn, parsed, dcID)
|
||||||
|
case secret.KindDD:
|
||||||
|
return checkDD(conn, parsed, dcID)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown secret kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEE(conn net.Conn, p *secret.Parsed, dcID int16) error {
|
||||||
|
// ee-секрет хранит домен как «сырой» хвост (часто 0xd0 + ASCII hostname). В TLS SNI нужен только hostname,
|
||||||
|
// как в официальном клиенте Telegram — иначе прокси сбрасывает соединение до ServerHello.
|
||||||
|
sni := faketls.SNIDomain(p.Domain)
|
||||||
|
if len(sni) == 0 {
|
||||||
|
sni = p.Domain
|
||||||
|
}
|
||||||
|
ch, err := faketls.BuildTdesktopClientHello(p.Key, sni)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fake-tls client hello: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(ch.Record); err != nil {
|
||||||
|
return fmt.Errorf("write client hello: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := faketls.ReadServerHello(conn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read server hello: %w", err)
|
||||||
|
}
|
||||||
|
if err := faketls.VerifyServerHelloTdesktop(resp, p.Key, ch.RandomField); err != nil {
|
||||||
|
return fmt.Errorf("verify server hello: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr, _, _, err := mtproxy.InitHeader(p.Key, dcID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mtproxy header: %w", err)
|
||||||
|
}
|
||||||
|
if err := faketls.WriteTLSApplicationData(conn, hdr); err != nil {
|
||||||
|
return fmt.Errorf("write mtproxy header: %w", err)
|
||||||
|
}
|
||||||
|
return waitPostPayload(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDD(conn net.Conn, p *secret.Parsed, dcID int16) error {
|
||||||
|
hdr, _, _, err := mtproxy.InitHeader(p.Key, dcID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mtproxy header: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(hdr); err != nil {
|
||||||
|
return fmt.Errorf("write mtproxy header: %w", err)
|
||||||
|
}
|
||||||
|
return waitPostPayload(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitPostPayload(conn net.Conn) error {
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
return ErrProxyClosed
|
||||||
|
}
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialTCP подключается к host:port так же, как типичное приложение: один вызов Dial к имени хоста.
|
||||||
|
// Раньше использовался обход AAAA/A с приоритетом IPv4 — это могло выбирать другой бэкенд, чем у ОС/клиента Telegram.
|
||||||
|
func dialTCP(ctx context.Context, host string, port int) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
c, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tcp dial: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
// SNIDomain returns hostname bytes for TLS SNI from the raw ee-secret domain suffix.
|
||||||
|
//
|
||||||
|
// В ссылке tg:// после 16-байтного ключа часто идёт «хвост» домена: один служебный байт (часто 0xd0)
|
||||||
|
// и дальше ASCII hostname в hex (например …d0632e61… → "c.api.stepa.one").
|
||||||
|
// В extension server_name нужно передавать только hostname в ASCII, иначе прокси обрывает сессию.
|
||||||
|
func SNIDomain(raw []byte) []byte {
|
||||||
|
if len(raw) >= 2 {
|
||||||
|
// Типичный префикс Telegram для ASCII-имени после ключа
|
||||||
|
if raw[0] == 0xd0 && raw[1] >= 0x20 && raw[1] < 0x7f {
|
||||||
|
return raw[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: пропускаем ведущие не-hostname байты до первой буквы/цифры/точки
|
||||||
|
i := 0
|
||||||
|
for i < len(raw) {
|
||||||
|
b := raw[i]
|
||||||
|
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if b == '.' || b == '-' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= len(raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return raw[i:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSNIDomain_TelegramD0Prefix(t *testing.T) {
|
||||||
|
// Domain tail from ee5ba9cf...dd0632e6170692e73746570612e6f6e65 (0xd0 + "c.api.stepa.one" hex)
|
||||||
|
d, err := hex.DecodeString("d0632e6170692e73746570612e6f6e65")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := SNIDomain(d)
|
||||||
|
want := []byte("c.api.stepa.one")
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extensions inside ClientHello must total exactly 403 bytes (TelethonFakeTLS / MTProxy expectation).
|
||||||
|
const extensionsTotal = 403
|
||||||
|
|
||||||
|
// Fixed TLS 1.2 cipher list from telethon-faketls.
|
||||||
|
var cipherSuites = []byte{
|
||||||
|
0xfa, 0xfa, 0x13, 0x01, 0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30,
|
||||||
|
0xcc, 0xa9, 0xcc, 0xa8, 0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35,
|
||||||
|
}
|
||||||
|
|
||||||
|
var extBeforeKeyShare = []byte{
|
||||||
|
0x00, 0x17, 0x00, 0x00,
|
||||||
|
0xff, 0x01, 0x00, 0x01, 0x00,
|
||||||
|
0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0xba, 0xba, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18,
|
||||||
|
0x00, 0x0b, 0x00, 0x02, 0x01, 0x00,
|
||||||
|
0x00, 0x23, 0x00, 0x00,
|
||||||
|
0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x74, 0x70, 0x2f, 0x31, 0x2e, 0x31,
|
||||||
|
0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, 0x05, 0x03, 0x08, 0x05, 0x05, 0x01, 0x08, 0x06, 0x06, 0x01,
|
||||||
|
0x00, 0x12, 0x00, 0x00,
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyShareHeader = []byte{0x00, 0x33, 0x00, 0x2b, 0x00, 0x29, 0xba, 0xba, 0x00, 0x01, 0x00, 0x00, 0x1d, 0x00, 0x20}
|
||||||
|
|
||||||
|
var extAfterKeyShare = []byte{
|
||||||
|
0x00, 0x2d, 0x00, 0x02, 0x01, 0x01,
|
||||||
|
0x00, 0x2b, 0x00, 0x0b, 0x0a, 0x9a, 0x9a, 0x03, 0x04, 0x03, 0x03, 0x03, 0x02, 0x03, 0x01,
|
||||||
|
0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02,
|
||||||
|
0x1a, 0x1a, 0x00, 0x01, 0x00,
|
||||||
|
}
|
||||||
|
|
||||||
|
func sniExtension(domain []byte) []byte {
|
||||||
|
entry := make([]byte, 0, 3+len(domain))
|
||||||
|
entry = append(entry, 0x00)
|
||||||
|
entry = append(entry, byte(len(domain)>>8), byte(len(domain)))
|
||||||
|
entry = append(entry, domain...)
|
||||||
|
list := make([]byte, 0, 2+len(entry))
|
||||||
|
list = append(list, byte(len(entry)>>8), byte(len(entry)))
|
||||||
|
list = append(list, entry...)
|
||||||
|
out := make([]byte, 0, 4+len(list))
|
||||||
|
out = append(out, 0x00, 0x00)
|
||||||
|
out = append(out, byte(len(list)>>8), byte(len(list)))
|
||||||
|
out = append(out, list...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExtensions(domain []byte, pubKey []byte) ([]byte, error) {
|
||||||
|
if len(pubKey) != 32 {
|
||||||
|
return nil, fmt.Errorf("key share pubkey: need 32 bytes")
|
||||||
|
}
|
||||||
|
var exts bytes.Buffer
|
||||||
|
exts.Write([]byte{0x4a, 0x4a, 0x00, 0x00})
|
||||||
|
exts.Write(sniExtension(domain))
|
||||||
|
exts.Write(extBeforeKeyShare)
|
||||||
|
exts.Write(keyShareHeader)
|
||||||
|
exts.Write(pubKey)
|
||||||
|
exts.Write(extAfterKeyShare)
|
||||||
|
|
||||||
|
core := exts.Len()
|
||||||
|
// padding extension: 00 15 + u16(len) + zeros; total extensions must be 403
|
||||||
|
padHdr := 4
|
||||||
|
padData := extensionsTotal - core - padHdr
|
||||||
|
if padData < 0 {
|
||||||
|
return nil, fmt.Errorf("mtproxy fake-tls: domain too long for fixed 403-byte extension block (core=%d)", core)
|
||||||
|
}
|
||||||
|
exts.Write([]byte{0x00, 0x15})
|
||||||
|
exts.Write([]byte{byte(padData >> 8), byte(padData)})
|
||||||
|
for i := 0; i < padData; i++ {
|
||||||
|
exts.WriteByte(0x00)
|
||||||
|
}
|
||||||
|
if exts.Len() != extensionsTotal {
|
||||||
|
return nil, fmt.Errorf("internal: extensions len %d != %d", exts.Len(), extensionsTotal)
|
||||||
|
}
|
||||||
|
return exts.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientHello is the initial raw TLS record and the 32-byte ClientHello.random field (post-HMAC).
|
||||||
|
type ClientHello struct {
|
||||||
|
Record []byte
|
||||||
|
RandomField []byte
|
||||||
|
SessionID []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildClientHello constructs the first wire record (517 bytes) compatible with telethon-faketls.
|
||||||
|
func BuildClientHello(secretKey []byte, domain []byte) (*ClientHello, error) {
|
||||||
|
if len(secretKey) != 16 {
|
||||||
|
return nil, fmt.Errorf("secret key must be 16 bytes")
|
||||||
|
}
|
||||||
|
pub, err := GenX25519PublicKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessionID, err := Rand32()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exts, err := buildExtensions(domain, pub)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientHello body = 508 bytes
|
||||||
|
var body bytes.Buffer
|
||||||
|
body.Write([]byte{0x03, 0x03})
|
||||||
|
randomZero := make([]byte, 32)
|
||||||
|
body.Write(randomZero)
|
||||||
|
body.WriteByte(32)
|
||||||
|
body.Write(sessionID)
|
||||||
|
body.Write([]byte{0x00, 0x20})
|
||||||
|
body.Write(cipherSuites)
|
||||||
|
body.WriteByte(0x01)
|
||||||
|
body.WriteByte(0x00)
|
||||||
|
body.Write([]byte{byte(extensionsTotal >> 8), byte(extensionsTotal & 0xff)})
|
||||||
|
body.Write(exts)
|
||||||
|
if body.Len() != 508 {
|
||||||
|
return nil, fmt.Errorf("internal: client hello body %d != 508", body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
handshake := make([]byte, 0, 4+508)
|
||||||
|
handshake = append(handshake, 0x01)
|
||||||
|
handshake = append(handshake, byte(508>>16), byte((508>>8)&0xff), byte(508&0xff))
|
||||||
|
handshake = append(handshake, body.Bytes()...)
|
||||||
|
|
||||||
|
record := make([]byte, 0, 5+len(handshake))
|
||||||
|
record = append(record, 0x16, 0x03, 0x01)
|
||||||
|
record = binary.BigEndian.AppendUint16(record, uint16(len(handshake)))
|
||||||
|
record = append(record, handshake...)
|
||||||
|
|
||||||
|
randomField := computeRandomField(secretKey, record)
|
||||||
|
copy(record[11:43], randomField)
|
||||||
|
|
||||||
|
return &ClientHello{Record: record, RandomField: randomField, SessionID: sessionID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeRandomField(secretKey []byte, record []byte) []byte {
|
||||||
|
msg := make([]byte, 0, len(record))
|
||||||
|
msg = append(msg, record[:11]...)
|
||||||
|
msg = append(msg, make([]byte, 32)...)
|
||||||
|
msg = append(msg, record[43:]...)
|
||||||
|
mac := hmac.New(sha256.New, secretKey)
|
||||||
|
mac.Write(msg)
|
||||||
|
digest := mac.Sum(nil)
|
||||||
|
ts := uint32(time.Now().Unix())
|
||||||
|
tsb := []byte{byte(ts), byte(ts >> 8), byte(ts >> 16), byte(ts >> 24)}
|
||||||
|
out := make([]byte, 32)
|
||||||
|
copy(out[:28], digest[:28])
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
out[28+i] = digest[28+i] ^ tsb[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildClientHello_Size(t *testing.T) {
|
||||||
|
key, err := hex.DecodeString("5ba9cf5cf84698032e5300c864544dd0")
|
||||||
|
if err != nil || len(key) != 16 {
|
||||||
|
t.Fatalf("key: %v len %d", err, len(key))
|
||||||
|
}
|
||||||
|
domain, err := hex.DecodeString("632e6170692e73746570612e6f6e65")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ch, err := BuildClientHello(key, domain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ch.Record) != 517 {
|
||||||
|
t.Fatalf("record len %d", len(ch.Record))
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(ch.Record, []byte{0x16, 0x03, 0x01}) {
|
||||||
|
t.Fatal("bad record header")
|
||||||
|
}
|
||||||
|
pl := int(ch.Record[3])<<8 | int(ch.Record[4])
|
||||||
|
if pl != 512 {
|
||||||
|
t.Fatalf("record payload len %d", pl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// Генерация ClientHello по логике Telegram Desktop (mtproto_tls_socket.cpp, PrepareClientHelloRules).
|
||||||
|
// См. https://github.com/telegramdesktop/tdesktop/blob/dev/Telegram/SourceFiles/mtproto/details/mtproto_tls_socket.cpp
|
||||||
|
|
||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kMaxGrease = 8
|
||||||
|
kClientHelloLimit = 2048
|
||||||
|
kHelloDigestLen = 32
|
||||||
|
kLengthSize = 2
|
||||||
|
kElementsM = 384
|
||||||
|
kAddedM = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildTdesktopClientHello строит запись TLS 1.2 ClientHello как в tdesktop (Ed25519, permutation, ECH padding, HMAC).
|
||||||
|
func BuildTdesktopClientHello(secretKey []byte, domain []byte) (*ClientHello, error) {
|
||||||
|
if len(secretKey) != 16 {
|
||||||
|
return nil, fmt.Errorf("secret key must be 16 bytes")
|
||||||
|
}
|
||||||
|
greases := prepareGreases()
|
||||||
|
p := newTdesktopPart(domain, greases)
|
||||||
|
writeClientHelloRules(p)
|
||||||
|
if p.err {
|
||||||
|
return nil, fmt.Errorf("tdesktop client hello: build failed")
|
||||||
|
}
|
||||||
|
p.finalize(secretKey)
|
||||||
|
if p.err {
|
||||||
|
return nil, fmt.Errorf("tdesktop client hello: finalize failed")
|
||||||
|
}
|
||||||
|
digest := p.extractDigest()
|
||||||
|
out := p.take()
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("tdesktop client hello: empty buffer")
|
||||||
|
}
|
||||||
|
return &ClientHello{
|
||||||
|
Record: out,
|
||||||
|
RandomField: append([]byte(nil), digest...),
|
||||||
|
SessionID: p.sessionID(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareGreases() []byte {
|
||||||
|
result := make([]byte, kMaxGrease)
|
||||||
|
if _, err := rand.Read(result); err != nil {
|
||||||
|
return bytesRepeat(0x0a, kMaxGrease)
|
||||||
|
}
|
||||||
|
for i := range result {
|
||||||
|
result[i] = (result[i] & 0xf0) + 0x0a
|
||||||
|
}
|
||||||
|
for i := 0; i < kMaxGrease; i += 2 {
|
||||||
|
if result[i] == result[i+1] {
|
||||||
|
result[i+1] ^= 0x10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesRepeat(b byte, n int) []byte {
|
||||||
|
out := make([]byte, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type tdesktopPart struct {
|
||||||
|
domain []byte
|
||||||
|
greases []byte
|
||||||
|
buf []byte
|
||||||
|
digestPosition int
|
||||||
|
err bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTdesktopPart(domain []byte, greases []byte) *tdesktopPart {
|
||||||
|
return &tdesktopPart{
|
||||||
|
domain: domain,
|
||||||
|
greases: greases,
|
||||||
|
digestPosition: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) grow(n int) []byte {
|
||||||
|
if p.err || n <= 0 || len(p.buf)+n > kClientHelloLimit {
|
||||||
|
p.err = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
off := len(p.buf)
|
||||||
|
p.buf = append(p.buf, make([]byte, n)...)
|
||||||
|
return p.buf[off : off+n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeString(b []byte) {
|
||||||
|
st := p.grow(len(b))
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(st, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeZeros(n int) {
|
||||||
|
already := len(p.buf)
|
||||||
|
st := p.grow(n)
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == kHelloDigestLen && p.digestPosition < 0 {
|
||||||
|
p.digestPosition = already
|
||||||
|
}
|
||||||
|
clear(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeGrease(seed int) {
|
||||||
|
if seed < 0 || seed >= len(p.greases) {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := p.grow(2)
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// В tdesktop: bytes::set_with_const(storage, _greases[seed]) — оба байта равны значению seed.
|
||||||
|
st[0] = p.greases[seed]
|
||||||
|
st[1] = p.greases[seed]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeRandom(n int) {
|
||||||
|
st := p.grow(n)
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(st); err != nil {
|
||||||
|
p.err = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeDomain() {
|
||||||
|
st := p.grow(len(p.domain))
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(st, p.domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeEd25519PublicKey() {
|
||||||
|
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := p.grow(32)
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(st, pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeScope(f func()) {
|
||||||
|
st := p.grow(kLengthSize)
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
already := len(p.buf)
|
||||||
|
f()
|
||||||
|
length := len(p.buf) - already
|
||||||
|
if length > 65535 {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint16(p.buf[already-2:already], uint16(length))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) newSubPart() *tdesktopPart {
|
||||||
|
return newTdesktopPart(p.domain, p.greases)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writePermutation(elements []func(*tdesktopPart)) {
|
||||||
|
var parts [][]byte
|
||||||
|
for _, el := range elements {
|
||||||
|
sub := p.newSubPart()
|
||||||
|
el(sub)
|
||||||
|
if sub.err {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts = append(parts, sub.take())
|
||||||
|
}
|
||||||
|
shuffleByteSlices(parts)
|
||||||
|
for _, b := range parts {
|
||||||
|
if p.err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := p.grow(len(b))
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(st, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shuffleByteSlices(s [][]byte) {
|
||||||
|
n := len(s)
|
||||||
|
for i := n - 1; i > 0; i-- {
|
||||||
|
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
|
||||||
|
j := 0
|
||||||
|
if err != nil {
|
||||||
|
j = i
|
||||||
|
} else {
|
||||||
|
j = int(jBig.Int64())
|
||||||
|
}
|
||||||
|
s[i], s[j] = s[j], s[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeM() {
|
||||||
|
kElements := kElementsM
|
||||||
|
kAdded := kAddedM
|
||||||
|
storage := p.grow(kElements*3 + kAdded)
|
||||||
|
if storage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
random := make([]byte, kElements*8+kAdded)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chars := storage
|
||||||
|
ints := make([]uint32, len(random)/4)
|
||||||
|
for i := 0; i < len(ints); i++ {
|
||||||
|
ints[i] = binary.LittleEndian.Uint32(random[i*4 : i*4+4])
|
||||||
|
}
|
||||||
|
ci := 0
|
||||||
|
for i := 0; i < kElements; i++ {
|
||||||
|
a := int(ints[i*2] % 3329)
|
||||||
|
b := int(ints[i*2+1] % 3329)
|
||||||
|
chars[ci] = byte(a & 255)
|
||||||
|
chars[ci+1] = byte((a >> 8) + ((b & 15) << 4))
|
||||||
|
chars[ci+2] = byte(b >> 4)
|
||||||
|
ci += 3
|
||||||
|
}
|
||||||
|
tail := storage[kElements*3:]
|
||||||
|
if _, err := rand.Read(tail); err != nil {
|
||||||
|
p.err = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writeE() {
|
||||||
|
lengths := []int{144, 176, 208, 240}
|
||||||
|
var pick [1]byte
|
||||||
|
if _, err := rand.Read(pick[:]); err != nil {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
length := lengths[int(pick[0])%len(lengths)]
|
||||||
|
p.writeRandom(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) writePadding() {
|
||||||
|
if p.err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur := len(p.buf)
|
||||||
|
if cur >= 513 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z := 513 - cur
|
||||||
|
p.writeString([]byte{0x00, 0x15})
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeZeros(z)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) finalize(key []byte) {
|
||||||
|
if p.err || p.digestPosition < 0 {
|
||||||
|
p.err = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write(p.buf)
|
||||||
|
sum := mac.Sum(nil)
|
||||||
|
copy(p.buf[p.digestPosition:p.digestPosition+kHelloDigestLen], sum)
|
||||||
|
injectTimestamp(p.buf[p.digestPosition : p.digestPosition+kHelloDigestLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectTimestamp(digest32 []byte) {
|
||||||
|
if len(digest32) != 32 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := digest32[28:32]
|
||||||
|
u := binary.LittleEndian.Uint32(st)
|
||||||
|
ts := uint32(time.Now().Unix())
|
||||||
|
u ^= ts
|
||||||
|
binary.LittleEndian.PutUint32(st, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) extractDigest() []byte {
|
||||||
|
if p.digestPosition < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), p.buf[p.digestPosition:p.digestPosition+kHelloDigestLen]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tdesktopPart) take() []byte {
|
||||||
|
if p.err {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), p.buf...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionID: первый 32-байтный Random сразу после префикса 0x20 (session id в ClientHello).
|
||||||
|
func (p *tdesktopPart) sessionID() []byte {
|
||||||
|
if p.err {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return firstR32Payload(p.buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstR32Payload(buf []byte) []byte {
|
||||||
|
pat := []byte{0x03, 0x03}
|
||||||
|
i := bytesIndex(buf, pat)
|
||||||
|
if i < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
j := i + len(pat) + 32 // после Z(32) digest
|
||||||
|
if j+1+32 > len(buf) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if buf[j] != 0x20 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), buf[j+1:j+1+32]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesIndex(hay []byte, needle []byte) int {
|
||||||
|
outer:
|
||||||
|
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||||
|
for k := range needle {
|
||||||
|
if hay[i+k] != needle[k] {
|
||||||
|
continue outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var cipherList32 = []byte{
|
||||||
|
0x13, 0x01, 0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30,
|
||||||
|
0xcc, 0xa9, 0xcc, 0xa8, 0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35,
|
||||||
|
0x01, 0x00,
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeClientHelloRules(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x16, 0x03, 0x01})
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeString([]byte{0x01, 0x00})
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeString([]byte{0x03, 0x03})
|
||||||
|
p.writeZeros(32)
|
||||||
|
p.writeString([]byte{0x20})
|
||||||
|
p.writeRandom(32)
|
||||||
|
p.writeString([]byte{0x00, 0x20})
|
||||||
|
p.writeGrease(0)
|
||||||
|
p.writeString(cipherList32)
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeGrease(2)
|
||||||
|
p.writeString([]byte{0x00, 0x00})
|
||||||
|
p.writePermutation(tdesktopPermutationElements())
|
||||||
|
p.writeGrease(3)
|
||||||
|
p.writeString([]byte{0x00, 0x01, 0x00})
|
||||||
|
p.writePadding()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tdesktopPermutationElements() []func(*tdesktopPart) {
|
||||||
|
return []func(*tdesktopPart){
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x00})
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeString([]byte{0x00})
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeDomain()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x0a, 0x00, 0x0c, 0x00, 0x0a})
|
||||||
|
p.writeGrease(4)
|
||||||
|
p.writeString([]byte{0x11, 0xec, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x0b, 0x00, 0x02, 0x01, 0x00})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{
|
||||||
|
0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, 0x05, 0x03,
|
||||||
|
0x08, 0x05, 0x05, 0x01, 0x08, 0x06, 0x06, 0x01,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{
|
||||||
|
0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x74, 0x70,
|
||||||
|
0x2f, 0x31, 0x2e, 0x31,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x12, 0x00, 0x00})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x17, 0x00, 0x00})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x23, 0x00, 0x00})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x2b, 0x00, 0x07, 0x06})
|
||||||
|
p.writeGrease(6)
|
||||||
|
p.writeString([]byte{0x03, 0x04, 0x03, 0x03})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x2d, 0x00, 0x02, 0x01, 0x01})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x00, 0x33, 0x04, 0xef, 0x04, 0xed})
|
||||||
|
p.writeGrease(4)
|
||||||
|
p.writeString([]byte{0x00, 0x01, 0x00, 0x11, 0xec, 0x04, 0xc0})
|
||||||
|
p.writeM()
|
||||||
|
p.writeEd25519PublicKey()
|
||||||
|
p.writeString([]byte{0x00, 0x1d, 0x00, 0x20})
|
||||||
|
p.writeEd25519PublicKey()
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0x44, 0xcd, 0x00, 0x05, 0x00, 0x03, 0x02, 0x68, 0x32})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0xfe, 0x0d})
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeString([]byte{0x00, 0x00, 0x01, 0x00, 0x01})
|
||||||
|
p.writeRandom(1)
|
||||||
|
p.writeString([]byte{0x00, 0x20})
|
||||||
|
p.writeRandom(32)
|
||||||
|
p.writeScope(func() {
|
||||||
|
p.writeE()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func(p *tdesktopPart) {
|
||||||
|
p.writeString([]byte{0xff, 0x01, 0x00, 0x01, 0x00})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildTdesktopClientHello_shape(t *testing.T) {
|
||||||
|
key, err := hex.DecodeString("5ba9cf5cf84698032e5300c864544dd0")
|
||||||
|
if err != nil || len(key) != 16 {
|
||||||
|
t.Fatalf("key: %v len %d", err, len(key))
|
||||||
|
}
|
||||||
|
domain, err := hex.DecodeString("632e6170692e73746570612e6f6e65")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ch, err := BuildTdesktopClientHello(key, domain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ch.Record) < 5 || len(ch.Record) > kClientHelloLimit {
|
||||||
|
t.Fatalf("record len %d", len(ch.Record))
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(ch.Record, []byte{0x16, 0x03, 0x01}) {
|
||||||
|
t.Fatal("bad record header")
|
||||||
|
}
|
||||||
|
if len(ch.RandomField) != 32 {
|
||||||
|
t.Fatalf("random/digest field len %d", len(ch.RandomField))
|
||||||
|
}
|
||||||
|
if len(ch.SessionID) != 32 {
|
||||||
|
t.Fatalf("session id len %d", len(ch.SessionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteTLSApplicationData wraps payload in TLS 1.2 application records (type 0x17).
|
||||||
|
func WriteTLSApplicationData(w io.Writer, p []byte) error {
|
||||||
|
const maxChunk = 16384
|
||||||
|
for off := 0; off < len(p); off += maxChunk {
|
||||||
|
end := off + maxChunk
|
||||||
|
if end > len(p) {
|
||||||
|
end = len(p)
|
||||||
|
}
|
||||||
|
chunk := p[off:end]
|
||||||
|
hdr := []byte{0x17, 0x03, 0x03, byte(len(chunk) >> 8), byte(len(chunk))}
|
||||||
|
if _, err := w.Write(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(chunk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Маркеры и разбор длин — как в mtproto_tls_socket.cpp (checkHelloParts12 / checkHelloParts34),
|
||||||
|
// а не фиксированные 127+6+3+2 байта telethon + «http»-хвост (на части хостов это давало ложную длину хвоста).
|
||||||
|
var (
|
||||||
|
kServerHelloPart1 = []byte{0x16, 0x03, 0x03}
|
||||||
|
kServerHelloPart3 = []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01, 0x17, 0x03, 0x03}
|
||||||
|
)
|
||||||
|
|
||||||
|
const kTLSLengthSize = 2
|
||||||
|
|
||||||
|
// ReadServerHello читает цепочку fake-TLS ServerHello до конца части, по которой считается digest (как в tdesktop).
|
||||||
|
func ReadServerHello(r io.Reader) ([]byte, error) {
|
||||||
|
parts1Size := len(kServerHelloPart1) + kTLSLengthSize
|
||||||
|
header := make([]byte, parts1Size)
|
||||||
|
if _, err := io.ReadFull(r, header); err != nil {
|
||||||
|
return nil, fmt.Errorf("read tls record header: %w", err)
|
||||||
|
}
|
||||||
|
part1Off := parts1Size - kTLSLengthSize - len(kServerHelloPart1)
|
||||||
|
if !bytes.Equal(header[part1Off:part1Off+len(kServerHelloPart1)], kServerHelloPart1) {
|
||||||
|
return nil, fmt.Errorf("server hello: bad tls prefix")
|
||||||
|
}
|
||||||
|
part2Size := int(binary.BigEndian.Uint16(header[parts1Size-kTLSLengthSize:]))
|
||||||
|
if part2Size <= 0 || part2Size > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("invalid tls fragment length %d", part2Size)
|
||||||
|
}
|
||||||
|
midLen := part2Size + len(kServerHelloPart3) + kTLSLengthSize
|
||||||
|
mid := make([]byte, midLen)
|
||||||
|
if _, err := io.ReadFull(r, mid); err != nil {
|
||||||
|
return nil, fmt.Errorf("read server hello body: %w", err)
|
||||||
|
}
|
||||||
|
part3Off := part2Size
|
||||||
|
if !bytes.Equal(mid[part3Off:part3Off+len(kServerHelloPart3)], kServerHelloPart3) {
|
||||||
|
return nil, fmt.Errorf("server hello: unexpected middle tls markers")
|
||||||
|
}
|
||||||
|
part4Size := int(binary.BigEndian.Uint16(mid[len(mid)-kTLSLengthSize:]))
|
||||||
|
if part4Size < 0 || part4Size > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("invalid final fragment length %d", part4Size)
|
||||||
|
}
|
||||||
|
tail := make([]byte, part4Size)
|
||||||
|
if _, err := io.ReadFull(r, tail); err != nil {
|
||||||
|
return nil, fmt.Errorf("read server hello final fragment: %w", err)
|
||||||
|
}
|
||||||
|
out := append(append(append([]byte{}, header...), mid...), tail...)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyServerHelloFull validates the proxy response (telethon-faketls rules).
|
||||||
|
func VerifyServerHelloFull(full []byte, secretKey []byte, clientRandom []byte, sessionID []byte) error {
|
||||||
|
if len(full) < 127+6+3+2 {
|
||||||
|
return fmt.Errorf("server hello too short")
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(full, []byte{0x16, 0x03, 0x03}) {
|
||||||
|
return fmt.Errorf("server hello: bad tls prefix")
|
||||||
|
}
|
||||||
|
marker := []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01, 0x17, 0x03, 0x03}
|
||||||
|
if len(full) < 127+len(marker) || !bytes.Equal(full[127:127+len(marker)], marker) {
|
||||||
|
return fmt.Errorf("server hello: unexpected middle tls markers")
|
||||||
|
}
|
||||||
|
sidStart := 11 + 32 + 1
|
||||||
|
if len(full) < sidStart+32 || !bytes.Equal(full[sidStart:sidStart+32], sessionID) {
|
||||||
|
return fmt.Errorf("server hello: session id mismatch")
|
||||||
|
}
|
||||||
|
patched := make([]byte, len(full))
|
||||||
|
copy(patched, full)
|
||||||
|
clear(patched[11:43])
|
||||||
|
mac := hmac.New(sha256.New, secretKey)
|
||||||
|
mac.Write(clientRandom)
|
||||||
|
mac.Write(patched)
|
||||||
|
want := mac.Sum(nil)
|
||||||
|
if !bytes.Equal(full[11:43], want) {
|
||||||
|
return fmt.Errorf("server hello: digest mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
kServerHelloDigestPosition = 11
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyServerHelloTdesktop проверяет ответ прокси по правилам mtproto_tls_socket.cpp (checkHelloDigest):
|
||||||
|
// HMAC-SHA256(key, client_digest||server_bytes) при обнулённом digest сервера в буфере.
|
||||||
|
func VerifyServerHelloTdesktop(serverResponse []byte, secretKey []byte, clientDigest []byte) error {
|
||||||
|
if len(secretKey) != 16 {
|
||||||
|
return fmt.Errorf("secret key must be 16 bytes")
|
||||||
|
}
|
||||||
|
if len(clientDigest) != 32 {
|
||||||
|
return fmt.Errorf("client digest must be 32 bytes")
|
||||||
|
}
|
||||||
|
if len(serverResponse) < kServerHelloDigestPosition+32 {
|
||||||
|
return fmt.Errorf("server hello too short for digest")
|
||||||
|
}
|
||||||
|
fulldata := make([]byte, 0, len(clientDigest)+len(serverResponse))
|
||||||
|
fulldata = append(fulldata, clientDigest...)
|
||||||
|
fulldata = append(fulldata, serverResponse...)
|
||||||
|
digestOff := len(clientDigest) + kServerHelloDigestPosition
|
||||||
|
digestCopy := append([]byte(nil), fulldata[digestOff:digestOff+32]...)
|
||||||
|
clear(fulldata[digestOff : digestOff+32])
|
||||||
|
mac := hmac.New(sha256.New, secretKey)
|
||||||
|
mac.Write(fulldata)
|
||||||
|
want := mac.Sum(nil)
|
||||||
|
if !bytes.Equal(digestCopy, want) {
|
||||||
|
return fmt.Errorf("server hello: digest mismatch (tdesktop)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package faketls
|
||||||
|
|
||||||
|
import (
|
||||||
|
crand "crypto/rand"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/curve25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenX25519PublicKey returns a valid X25519 public key (32 bytes) for the ClientHello key_share extension.
|
||||||
|
// Some MTProxy builds validate the point; a random quadratic residue mod P (older telethon-faketls trick) is rejected.
|
||||||
|
func GenX25519PublicKey() ([]byte, error) {
|
||||||
|
var priv, pub [32]byte
|
||||||
|
if _, err := crand.Read(priv[:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
curve25519.ScalarBaseMult(&pub, &priv)
|
||||||
|
return pub[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rand32 returns 32 random bytes (session id).
|
||||||
|
func Rand32() ([]byte, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := crand.Read(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package mtproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
crand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObfuscateTagRandomizedIntermediate matches Telethon RandomizedIntermediatePacketCodec.
|
||||||
|
var ObfuscateTagRandomizedIntermediate = []byte{0xdd, 0xdd, 0xdd, 0xdd}
|
||||||
|
|
||||||
|
// randSource is injectable for tests.
|
||||||
|
var randSource io.Reader = crand.Reader
|
||||||
|
|
||||||
|
// InitHeader returns the 64-byte first payload (MTProxyIO.init_header) and CTR streams.
|
||||||
|
func InitHeader(secret16 []byte, dcID int16) (header64 []byte, enc cipher.Stream, dec cipher.Stream, err error) {
|
||||||
|
if len(secret16) != 16 {
|
||||||
|
return nil, nil, nil, fmt.Errorf("mtproxy secret must be 16 bytes")
|
||||||
|
}
|
||||||
|
sec := secret16
|
||||||
|
|
||||||
|
keywords := [][]byte{
|
||||||
|
[]byte("PVrG"),
|
||||||
|
[]byte("GET "),
|
||||||
|
[]byte("POST"),
|
||||||
|
{0xee, 0xee, 0xee, 0xee},
|
||||||
|
}
|
||||||
|
var rnd [64]byte
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(randSource, rnd[:]); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if rnd[0] == 0xef {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bad := false
|
||||||
|
for _, k := range keywords {
|
||||||
|
if len(k) <= 4 && bytes.Equal(rnd[:len(k)], k) {
|
||||||
|
bad = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rnd[4] == 0 && rnd[5] == 0 && rnd[6] == 0 && rnd[7] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
random := rnd[:]
|
||||||
|
rev := make([]byte, 48)
|
||||||
|
for i := 0; i < 48; i++ {
|
||||||
|
rev[i] = random[55-i]
|
||||||
|
}
|
||||||
|
|
||||||
|
encKey := sha256Sum(append(append([]byte{}, random[8:40]...), sec...))
|
||||||
|
encIV := random[40:56]
|
||||||
|
decKey := sha256Sum(append(append([]byte{}, rev[:32]...), sec...))
|
||||||
|
decIV := rev[32:48]
|
||||||
|
|
||||||
|
enc, err = newCTR(encKey, encIV)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
dec, err = newCTR(decKey, decIV)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
copy(buf, random)
|
||||||
|
copy(buf[56:60], ObfuscateTagRandomizedIntermediate)
|
||||||
|
dcBytes := make([]byte, 2)
|
||||||
|
binary.LittleEndian.PutUint16(dcBytes, uint16(dcID))
|
||||||
|
copy(buf[60:62], dcBytes)
|
||||||
|
|
||||||
|
encChunk := make([]byte, 8)
|
||||||
|
copy(encChunk, buf[56:64])
|
||||||
|
enc.XORKeyStream(encChunk, encChunk)
|
||||||
|
copy(buf[56:64], encChunk)
|
||||||
|
|
||||||
|
return buf, enc, dec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Sum(b []byte) []byte {
|
||||||
|
h := sha256.Sum256(b)
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCTR(key, iv []byte) (cipher.Stream, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewCTR(block, iv), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package mtproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitHeader_Length(t *testing.T) {
|
||||||
|
key := make([]byte, 16)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
hdr, enc, dec, err := InitHeader(key, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(hdr) != 64 {
|
||||||
|
t.Fatalf("header len %d", len(hdr))
|
||||||
|
}
|
||||||
|
_ = enc
|
||||||
|
_ = dec
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package parseurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target is host, port, secret string from tg://proxy or explicit flags.
|
||||||
|
type Target struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTGProxy parses tg://proxy?server=&port=&secret= or t.me/proxy style query.
|
||||||
|
func ParseTGProxy(raw string) (*Target, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("empty proxy url")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Scheme != "tg" {
|
||||||
|
return nil, fmt.Errorf("expected tg:// scheme, got %q", u.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := u.Hostname()
|
||||||
|
if host != "" && host != "proxy" {
|
||||||
|
return nil, fmt.Errorf("unexpected tg host %q", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
server := strings.TrimSpace(q.Get("server"))
|
||||||
|
if server == "" {
|
||||||
|
return nil, fmt.Errorf("missing server parameter")
|
||||||
|
}
|
||||||
|
portStr := strings.TrimSpace(q.Get("port"))
|
||||||
|
if portStr == "" {
|
||||||
|
return nil, fmt.Errorf("missing port parameter")
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("invalid port %q", portStr)
|
||||||
|
}
|
||||||
|
sec := strings.TrimSpace(q.Get("secret"))
|
||||||
|
if sec == "" {
|
||||||
|
return nil, fmt.Errorf("missing secret parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Target{Host: server, Port: port, Secret: sec}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package parseurl
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTGProxy(t *testing.T) {
|
||||||
|
raw := "tg://proxy?server=example.com&port=443&secret=ee0102030405060708090a0b0c0d0e0f10632e6578616d706c652e636f6d"
|
||||||
|
tg, err := ParseTGProxy(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tg.Host != "example.com" || tg.Port != 443 {
|
||||||
|
t.Fatalf("%+v", tg)
|
||||||
|
}
|
||||||
|
if tg.Secret == "" {
|
||||||
|
t.Fatal("secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindEE Kind = iota
|
||||||
|
KindDD
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parsed holds normalized MTProxy secret material for handshake and MTProxyIO.
|
||||||
|
type Parsed struct {
|
||||||
|
Kind Kind
|
||||||
|
|
||||||
|
// Key is always 16 bytes (MTProxy user secret).
|
||||||
|
Key []byte
|
||||||
|
|
||||||
|
// Domain is raw bytes after the key in ee-secrets (SNI payload); empty for dd.
|
||||||
|
Domain []byte
|
||||||
|
|
||||||
|
// RawHex is the original secret string (lowercase hex) for logging.
|
||||||
|
RawHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidSecret = errors.New("invalid mtproxy secret")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse decodes Telegram MTProxy secret from tg:// links (hex or legacy base64).
|
||||||
|
func Parse(secretStr string) (*Parsed, error) {
|
||||||
|
s := strings.TrimSpace(strings.ToLower(secretStr))
|
||||||
|
if s == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty", ErrInvalidSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip common tg:// noise
|
||||||
|
s = strings.TrimPrefix(s, "0x")
|
||||||
|
|
||||||
|
var raw []byte
|
||||||
|
if isHex(s) {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: hex: %v", ErrInvalidSecret, err)
|
||||||
|
}
|
||||||
|
raw = b
|
||||||
|
} else {
|
||||||
|
// Telegram sometimes uses base64 secrets
|
||||||
|
pad := strings.Repeat("=", (4-len(s)%4)%4)
|
||||||
|
b, err := base64.StdEncoding.DecodeString(s + pad)
|
||||||
|
if err != nil {
|
||||||
|
b, err = base64.URLEncoding.DecodeString(s + pad)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: not hex or base64: %v", ErrInvalidSecret, err)
|
||||||
|
}
|
||||||
|
raw = b
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: no payload", ErrInvalidSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch raw[0] {
|
||||||
|
case 0xdd:
|
||||||
|
if len(raw) != 17 {
|
||||||
|
return nil, fmt.Errorf("%w: dd secret must be 17 bytes, got %d", ErrInvalidSecret, len(raw))
|
||||||
|
}
|
||||||
|
key := make([]byte, 16)
|
||||||
|
copy(key, raw[1:])
|
||||||
|
return &Parsed{
|
||||||
|
Kind: KindDD,
|
||||||
|
Key: key,
|
||||||
|
Domain: nil,
|
||||||
|
RawHex: s,
|
||||||
|
}, nil
|
||||||
|
case 0xee:
|
||||||
|
if len(raw) < 18 {
|
||||||
|
return nil, fmt.Errorf("%w: ee secret too short", ErrInvalidSecret)
|
||||||
|
}
|
||||||
|
key := make([]byte, 16)
|
||||||
|
copy(key, raw[1:17])
|
||||||
|
domain := make([]byte, len(raw)-17)
|
||||||
|
copy(domain, raw[17:])
|
||||||
|
return &Parsed{
|
||||||
|
Kind: KindEE,
|
||||||
|
Key: key,
|
||||||
|
Domain: domain,
|
||||||
|
RawHex: s,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: unknown first byte 0x%02x (expected ee/dd)", ErrInvalidSecret, raw[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHex(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9', r >= 'a' && r <= 'f':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s)%2 == 0 && len(s) >= 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseEE_UserExample(t *testing.T) {
|
||||||
|
s := "ee5ba9cf5cf84698032e5300c864544dd0632e6170692e73746570612e6f6e65"
|
||||||
|
p, err := Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Kind != KindEE {
|
||||||
|
t.Fatalf("kind %v", p.Kind)
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if raw[0] != 0xee {
|
||||||
|
t.Fatal("marker")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(p.Key, raw[1:17]) {
|
||||||
|
t.Fatalf("key: got %x want %x", p.Key, raw[1:17])
|
||||||
|
}
|
||||||
|
if !bytes.Equal(p.Domain, raw[17:]) {
|
||||||
|
t.Fatalf("domain: got %x want %x", p.Domain, raw[17:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDD(t *testing.T) {
|
||||||
|
s := "dd00000000000000000000000000000000"
|
||||||
|
p, err := Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Kind != KindDD {
|
||||||
|
t.Fatalf("kind %v", p.Kind)
|
||||||
|
}
|
||||||
|
for _, b := range p.Key {
|
||||||
|
if b != 0 {
|
||||||
|
t.Fatal("expected zero key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user