Compare commits

..

27 Commits

Author SHA1 Message Date
Alexey c43de1bd2a Update release.yml 2026-03-24 22:36:25 +03:00
Alexey 101efe45b7 Update Dockerfile 2026-03-24 22:36:20 +03:00
Alexey 11df61c6ac Update release.yml 2026-03-24 22:18:34 +03:00
Alexey 08684bcbd2 Update Cargo.toml 2026-03-24 22:03:12 +03:00
Alexey 744fb4425f TLS Validator: Unknown SNI as WARN in Log: merge pull request #579 from telemt/flow
TLS Validator: Unknown SNI as WARN in Log
2026-03-24 22:01:09 +03:00
Alexey 80cb1bc221 Merge branch 'main' into flow 2026-03-24 22:00:51 +03:00
Alexey 8461556b02 Update release.yml 2026-03-24 22:00:32 +03:00
Alexey cfd516edf3 Update Cargo.toml 2026-03-24 21:41:14 +03:00
Alexey 803c2c0492 Update release.yml 2026-03-24 21:40:53 +03:00
Alexey b762bd029f Merge branch 'main' into flow 2026-03-24 21:18:54 +03:00
Alexey 761679d306 Update test.yml 2026-03-24 21:18:13 +03:00
Alexey 41668b153d Update test.yml 2026-03-24 21:14:12 +03:00
Alexey 1d2f88ad29 Merge branch 'main' into flow 2026-03-24 21:11:11 +03:00
Alexey 80917f5abc Update test.yml 2026-03-24 21:10:56 +03:00
Alexey dc61d300ab Bump 2026-03-24 21:02:43 +03:00
Alexey ae16080de5 TLS Validator: Unknown SNI as WARN in Log 2026-03-24 21:01:41 +03:00
Alexey b8ca1fc166 Update Dockerfile 2026-03-24 20:55:32 +03:00
Alexey f9986944df Update release.yml 2026-03-24 20:53:56 +03:00
Alexey cb877c2bc3 Update release profile settings for better optimization: merge pull request #574 from vladon/main
Update release profile settings for better optimization
2026-03-24 14:10:04 +03:00
Vladislav Yaroslavlev 4426082c17 Update release profile settings for better optimization 2026-03-24 14:01:49 +03:00
Alexey 22097f8c7c Update Dockerfile 2026-03-24 11:46:49 +03:00
Alexey 1450af60a0 Update Dockerfile 2026-03-24 11:41:53 +03:00
Alexey f1cc8d65f2 Update release.yml 2026-03-24 11:12:03 +03:00
Alexey ec7e808daf Update release.yml 2026-03-24 11:05:50 +03:00
Alexey e4b7e23e76 New TLS-Fetcher + TLS SNI Validator + Upstream-driver getProxySecret/Config + Workflow Tunings + Redesign Quotas on Atomics + Tests Swap: merge pull request #569 from telemt/flow
New TLS-Fetcher + TLS SNI Validator + Upstream-driver getProxySecret/Config + Workflow Tunings + Redesign Quotas on Atomics + Tests Swap
2026-03-24 10:56:15 +03:00
Alexey 8b92b80b4a Rustks CryptoProvider fixes + Rustfmt 2026-03-24 10:33:06 +03:00
Alexey f7868aa00f Advanced TLS Fetcher
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-03-24 09:58:24 +03:00
14 changed files with 1360 additions and 275 deletions
+220 -66
View File
@@ -5,27 +5,69 @@ on:
tags: tags:
- '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch: workflow_dispatch:
inputs:
tag:
description: 'Release tag (example: 3.3.15)'
required: true
type: string
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref_name }}-${{ github.event.inputs.tag || 'auto' }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions:
contents: read contents: read
packages: write
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
BINARY_NAME: telemt BINARY_NAME: telemt
jobs: jobs:
# ========================== prepare:
# GNU / glibc name: Prepare
# ==========================
build-gnu:
name: GNU ${{ matrix.target }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.vars.outputs.version }}
prerelease: ${{ steps.vars.outputs.prerelease }}
steps:
- name: Resolve version
id: vars
shell: bash
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.tag }}"
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
VERSION="${VERSION#refs/tags/}"
if [ -z "${VERSION}" ]; then
echo "Release version is empty" >&2
exit 1
fi
if [[ "${VERSION}" == *-* ]]; then
PRERELEASE=true
else
PRERELEASE=false
fi
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "prerelease=${PRERELEASE}" >> "${GITHUB_OUTPUT}"
# ==========================
# GNU / glibc
# ==========================
build-gnu:
name: GNU ${{ matrix.asset }}
runs-on: ubuntu-latest
needs: prepare
container: container:
image: rust:slim-bookworm image: rust:slim-bookworm
@@ -35,8 +77,15 @@ jobs:
include: include:
- target: x86_64-unknown-linux-gnu - target: x86_64-unknown-linux-gnu
asset: telemt-x86_64-linux-gnu asset: telemt-x86_64-linux-gnu
cpu: baseline
- target: x86_64-unknown-linux-gnu
asset: telemt-x86_64-v3-linux-gnu
cpu: v3
- target: aarch64-unknown-linux-gnu - target: aarch64-unknown-linux-gnu
asset: telemt-aarch64-linux-gnu asset: telemt-aarch64-linux-gnu
cpu: generic
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -62,48 +111,65 @@ jobs:
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: | path: |
~/.cargo/registry /usr/local/cargo/registry
~/.cargo/git /usr/local/cargo/git
target target
key: gnu-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} key: gnu-${{ matrix.asset }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
gnu-${{ matrix.asset }}-
gnu-
- name: Build - name: Build
shell: bash
run: | run: |
set -euo pipefail
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
export CC=aarch64-linux-gnu-gcc export CC=aarch64-linux-gnu-gcc
export CXX=aarch64-linux-gnu-g++ export CXX=aarch64-linux-gnu-g++
export RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc" export RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc -C lto=fat -C panic=abort"
else else
export CC=clang export CC=clang
export CXX=clang++ export CXX=clang++
export RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
if [ "${{ matrix.cpu }}" = "v3" ]; then
CPU_FLAGS="-C target-cpu=x86-64-v3"
else
CPU_FLAGS="-C target-cpu=x86-64"
fi
export RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld -C lto=fat -C panic=abort ${CPU_FLAGS}"
fi fi
cargo build --release --target ${{ matrix.target }} cargo build --release --target ${{ matrix.target }} -j "$(nproc)"
- name: Package - name: Package
shell: bash
run: | run: |
set -euo pipefail
mkdir -p dist mkdir -p dist
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} dist/telemt cp "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}" dist/telemt
cd dist cd dist
tar -czf ${{ matrix.asset }}.tar.gz \ tar -czf "${{ matrix.asset }}.tar.gz" \
--owner=0 --group=0 --numeric-owner \ --owner=0 --group=0 --numeric-owner \
telemt telemt
sha256sum ${{ matrix.asset }}.tar.gz > ${{ matrix.asset }}.sha256 sha256sum "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.asset }} name: ${{ matrix.asset }}
path: dist/* path: dist/*
# ========================== # ==========================
# MUSL # MUSL
# ========================== # ==========================
build-musl: build-musl:
name: MUSL ${{ matrix.target }} name: MUSL ${{ matrix.asset }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: prepare
container: container:
image: rust:slim-bookworm image: rust:slim-bookworm
@@ -114,8 +180,15 @@ jobs:
include: include:
- target: x86_64-unknown-linux-musl - target: x86_64-unknown-linux-musl
asset: telemt-x86_64-linux-musl asset: telemt-x86_64-linux-musl
cpu: baseline
- target: x86_64-unknown-linux-musl
asset: telemt-x86_64-v3-linux-musl
cpu: v3
- target: aarch64-unknown-linux-musl - target: aarch64-unknown-linux-musl
asset: telemt-aarch64-linux-musl asset: telemt-aarch64-linux-musl
cpu: generic
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -123,7 +196,42 @@ jobs:
- name: Install deps - name: Install deps
run: | run: |
apt-get update apt-get update
apt-get install -y musl-tools pkg-config curl apt-get install -y \
musl-tools \
pkg-config \
curl
- uses: actions/cache@v4
if: matrix.target == 'aarch64-unknown-linux-musl'
with:
path: ~/.musl-aarch64
key: musl-toolchain-aarch64-v1
- name: Install aarch64 musl toolchain
if: matrix.target == 'aarch64-unknown-linux-musl'
shell: bash
run: |
set -euo pipefail
TOOLCHAIN_DIR="$HOME/.musl-aarch64"
ARCHIVE="aarch64-linux-musl-cross.tgz"
URL="https://github.com/telemt/telemt/releases/download/toolchains/${ARCHIVE}"
if [ -x "${TOOLCHAIN_DIR}/bin/aarch64-linux-musl-gcc" ]; then
echo "MUSL toolchain cached"
else
curl -fL \
--retry 5 \
--retry-delay 3 \
--connect-timeout 10 \
--max-time 120 \
-o "${ARCHIVE}" "${URL}"
mkdir -p "${TOOLCHAIN_DIR}"
tar -xzf "${ARCHIVE}" --strip-components=1 -C "${TOOLCHAIN_DIR}"
fi
echo "${TOOLCHAIN_DIR}/bin" >> "${GITHUB_PATH}"
- name: Add rust target - name: Add rust target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
@@ -134,44 +242,62 @@ jobs:
/usr/local/cargo/registry /usr/local/cargo/registry
/usr/local/cargo/git /usr/local/cargo/git
target target
key: musl-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} key: musl-${{ matrix.asset }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
musl-${{ matrix.asset }}-
musl-
- name: Build - name: Build
shell: bash
run: | run: |
set -euo pipefail
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
export CC=aarch64-linux-musl-gcc export CC=aarch64-linux-musl-gcc
export RUSTFLAGS="-C target-feature=+crt-static -C linker=aarch64-linux-musl-gcc" export CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
export RUSTFLAGS="-C target-feature=+crt-static -C linker=aarch64-linux-musl-gcc -C lto=fat -C panic=abort"
else else
export CC=musl-gcc export CC=musl-gcc
export RUSTFLAGS="-C target-feature=+crt-static" export CC_x86_64_unknown_linux_musl=musl-gcc
if [ "${{ matrix.cpu }}" = "v3" ]; then
CPU_FLAGS="-C target-cpu=x86-64-v3"
else
CPU_FLAGS="-C target-cpu=x86-64"
fi
export RUSTFLAGS="-C target-feature=+crt-static -C lto=fat -C panic=abort ${CPU_FLAGS}"
fi fi
cargo build --release --target ${{ matrix.target }} cargo build --release --target ${{ matrix.target }} -j "$(nproc)"
- name: Package - name: Package
shell: bash
run: | run: |
set -euo pipefail
mkdir -p dist mkdir -p dist
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} dist/telemt cp "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}" dist/telemt
cd dist cd dist
tar -czf ${{ matrix.asset }}.tar.gz \ tar -czf "${{ matrix.asset }}.tar.gz" \
--owner=0 --group=0 --numeric-owner \ --owner=0 --group=0 --numeric-owner \
telemt telemt
sha256sum ${{ matrix.asset }}.tar.gz > ${{ matrix.asset }}.sha256 sha256sum "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.asset }} name: ${{ matrix.asset }}
path: dist/* path: dist/*
# ========================== # ==========================
# Release # Release
# ========================== # ==========================
release: release:
name: Release name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-gnu, build-musl] needs: [prepare, build-gnu, build-musl]
permissions: permissions:
contents: write contents: write
@@ -181,25 +307,30 @@ jobs:
with: with:
path: artifacts path: artifacts
- name: Flatten - name: Flatten artifacts
shell: bash
run: | run: |
mkdir dist set -euo pipefail
mkdir -p dist
find artifacts -type f -exec cp {} dist/ \; find artifacts -type f -exec cp {} dist/ \;
- name: Create Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ needs.prepare.outputs.version }}
target_commitish: ${{ github.sha }}
files: dist/* files: dist/*
generate_release_notes: true generate_release_notes: true
prerelease: ${{ contains(github.ref, '-') }} prerelease: ${{ needs.prepare.outputs.prerelease == 'true' }}
overwrite_files: true
# ========================== # ==========================
# Docker (FROM RELEASE) # Docker
# ========================== # ==========================
docker: docker:
name: Docker (from release) name: Docker
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: release needs: [prepare, release]
permissions: permissions:
contents: read contents: read
@@ -208,28 +339,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install gh
run: apt-get update && apt-get install -y gh
- name: Extract version
id: vars
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download binary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir dist
gh release download ${{ steps.vars.outputs.VERSION }} \
--repo ${{ github.repository }} \
--pattern "telemt-x86_64-linux-musl.tar.gz" \
--dir dist
tar -xzf dist/telemt-x86_64-linux-musl.tar.gz -C dist
chmod +x dist/telemt
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
@@ -238,14 +349,57 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Probe release assets
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
for asset in \
telemt-x86_64-linux-musl.tar.gz \
telemt-x86_64-linux-musl.tar.gz.sha256 \
telemt-aarch64-linux-musl.tar.gz \
telemt-aarch64-linux-musl.tar.gz.sha256
do
curl -fsIL \
--retry 10 \
--retry-delay 3 \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/${asset}" \
> /dev/null
done
- name: Compute image tags
id: meta
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
IMAGE="$(echo "ghcr.io/${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
TAGS="${IMAGE}:${VERSION}"
if [[ "${VERSION}" != *-* ]]; then
TAGS="${TAGS}"$'\n'"${IMAGE}:latest"
fi
{
echo "tags<<EOF"
printf '%s\n' "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Build & Push - name: Build & Push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true
pull: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: | tags: ${{ steps.meta.outputs.tags }}
ghcr.io/${{ github.repository }}:${{ steps.vars.outputs.VERSION }}
ghcr.io/${{ github.repository }}:latest
build-args: | build-args: |
BINARY=dist/telemt TELEMT_REPOSITORY=${{ github.repository }}
TELEMT_VERSION=${{ needs.prepare.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+20 -8
View File
@@ -54,14 +54,20 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.cargo/bin
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-nextest-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo-nextest-
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- run: cargo test --verbose - name: Install cargo-nextest
run: cargo install --locked cargo-nextest || true
- name: Run tests with nextest
run: cargo nextest run -j "$(nproc)"
# ========================== # ==========================
# Clippy # Clippy
@@ -88,11 +94,13 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo-clippy-
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- run: cargo clippy -- --cap-lints warn - name: Run clippy
run: cargo clippy -j "$(nproc)" -- --cap-lints warn
# ========================== # ==========================
# Udeps # Udeps
@@ -108,20 +116,24 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with:
components: rust-src
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.cargo/bin
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-udeps-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo-udeps-
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- name: Install cargo-udeps - name: Install cargo-udeps
run: cargo install cargo-udeps || true run: cargo install --locked cargo-udeps || true
# тоже не валит билд - name: Run udeps
- run: cargo udeps || true run: cargo udeps -j "$(nproc)" || true
Generated
+1 -1
View File
@@ -2793,7 +2793,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "telemt" name = "telemt"
version = "3.3.30" version = "3.3.31"
dependencies = [ dependencies = [
"aes", "aes",
"anyhow", "anyhow",
+4 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telemt" name = "telemt"
version = "3.3.30" version = "3.3.31"
edition = "2024" edition = "2024"
[features] [features]
@@ -83,4 +83,6 @@ name = "crypto_bench"
harness = false harness = false
[profile.release] [profile.release]
lto = "thin" lto = "fat"
codegen-units = 1
+57 -30
View File
@@ -1,47 +1,74 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
ARG BINARY ARG TELEMT_REPOSITORY=telemt/telemt
ARG TELEMT_VERSION=latest
# ========================== # ==========================
# Stage: minimal # Minimal Image
# ========================== # ==========================
FROM debian:12-slim AS minimal FROM debian:12-slim AS minimal
RUN apt-get update && apt-get install -y --no-install-recommends \ ARG TARGETARCH
binutils \ ARG TELEMT_REPOSITORY
curl \ ARG TELEMT_VERSION
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \ RUN set -eux; \
\ apt-get update; \
&& curl -fL \ apt-get install -y --no-install-recommends \
binutils \
ca-certificates \
curl \
tar; \
rm -rf /var/lib/apt/lists/*
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) ASSET="telemt-x86_64-linux-musl.tar.gz" ;; \
arm64) ASSET="telemt-aarch64-linux-musl.tar.gz" ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
VERSION="${TELEMT_VERSION#refs/tags/}"; \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then \
BASE_URL="https://github.com/${TELEMT_REPOSITORY}/releases/latest/download"; \
else \
BASE_URL="https://github.com/${TELEMT_REPOSITORY}/releases/download/${VERSION}"; \
fi; \
curl -fL \
--retry 5 \ --retry 5 \
--retry-delay 3 \ --retry-delay 3 \
--connect-timeout 10 \ --connect-timeout 10 \
--max-time 120 \ --max-time 120 \
-o /tmp/upx.tar.xz \ -o "/tmp/${ASSET}" \
https://github.com/telemt/telemt/releases/download/toolchains/upx-amd64_linux.tar.xz \ "${BASE_URL}/${ASSET}"; \
&& tar -xf /tmp/upx.tar.xz -C /tmp \ curl -fL \
&& mv /tmp/upx*/upx /usr/local/bin/upx \ --retry 5 \
&& chmod +x /usr/local/bin/upx \ --retry-delay 3 \
&& rm -rf /tmp/upx* --connect-timeout 10 \
--max-time 120 \
COPY ${BINARY} /telemt -o "/tmp/${ASSET}.sha256" \
"${BASE_URL}/${ASSET}.sha256"; \
RUN strip /telemt || true cd /tmp; \
RUN upx --best --lzma /telemt || true sha256sum -c "${ASSET}.sha256"; \
tar -xzf "${ASSET}" -C /tmp; \
test -f /tmp/telemt; \
install -m 0755 /tmp/telemt /telemt; \
strip --strip-unneeded /telemt || true; \
rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt
# ========================== # ==========================
# Debug image # Debug Image
# ========================== # ==========================
FROM debian:12-slim AS debug FROM debian:12-slim AS debug
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN set -eux; \
ca-certificates \ apt-get update; \
tzdata \ apt-get install -y --no-install-recommends \
curl \ ca-certificates \
iproute2 \ tzdata \
busybox \ curl \
&& rm -rf /var/lib/apt/lists/* iproute2 \
busybox; \
rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -54,7 +81,7 @@ ENTRYPOINT ["/app/telemt"]
CMD ["config.toml"] CMD ["config.toml"]
# ========================== # ==========================
# Production (REAL distroless) # Production Distroless on MUSL
# ========================== # ==========================
FROM gcr.io/distroless/static-debian12 AS prod FROM gcr.io/distroless/static-debian12 AS prod
@@ -68,4 +95,4 @@ USER nonroot:nonroot
EXPOSE 443 9090 9091 EXPOSE 443 9090 9091
ENTRYPOINT ["/app/telemt"] ENTRYPOINT ["/app/telemt"]
CMD ["config.toml"] CMD ["config.toml"]
+16
View File
@@ -71,6 +71,22 @@ pub(crate) fn default_tls_fetch_scope() -> String {
String::new() String::new()
} }
pub(crate) fn default_tls_fetch_attempt_timeout_ms() -> u64 {
5_000
}
pub(crate) fn default_tls_fetch_total_budget_ms() -> u64 {
15_000
}
pub(crate) fn default_tls_fetch_strict_route() -> bool {
true
}
pub(crate) fn default_tls_fetch_profile_cache_ttl_secs() -> u64 {
600
}
pub(crate) fn default_mask_port() -> u16 { pub(crate) fn default_mask_port() -> u16 {
443 443
} }
+111 -1
View File
@@ -1,6 +1,6 @@
#![allow(deprecated)] #![allow(deprecated)]
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -977,6 +977,28 @@ impl ProxyConfig {
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing. // Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string(); config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
if config.censorship.tls_fetch.profiles.is_empty() {
config.censorship.tls_fetch.profiles = TlsFetchConfig::default().profiles;
} else {
let mut seen = HashSet::new();
config
.censorship
.tls_fetch
.profiles
.retain(|profile| seen.insert(*profile));
}
if config.censorship.tls_fetch.attempt_timeout_ms == 0 {
return Err(ProxyError::Config(
"censorship.tls_fetch.attempt_timeout_ms must be > 0".to_string(),
));
}
if config.censorship.tls_fetch.total_budget_ms == 0 {
return Err(ProxyError::Config(
"censorship.tls_fetch.total_budget_ms must be > 0".to_string(),
));
}
// Merge primary + extra TLS domains, deduplicate (primary always first). // Merge primary + extra TLS domains, deduplicate (primary always first).
if !config.censorship.tls_domains.is_empty() { if !config.censorship.tls_domains.is_empty() {
let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len()); let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len());
@@ -2459,6 +2481,94 @@ mod tests {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
fn tls_fetch_defaults_are_applied() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_defaults_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.censorship.tls_fetch.profiles,
TlsFetchConfig::default().profiles
);
assert!(cfg.censorship.tls_fetch.strict_route);
assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000);
assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000);
assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600);
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_profiles_are_deduplicated_preserving_order() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[censorship.tls_fetch]
profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"]
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(
cfg.censorship.tls_fetch.profiles,
vec![
TlsFetchProfile::CompatTls12,
TlsFetchProfile::ModernChromeLike,
TlsFetchProfile::LegacyMinimal
]
);
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_attempt_timeout_zero_is_rejected() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[censorship.tls_fetch]
attempt_timeout_ms = 0
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test]
fn tls_fetch_total_budget_zero_is_rejected() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[censorship.tls_fetch]
total_budget_ms = 0
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0"));
let _ = std::fs::remove_file(path);
}
#[test] #[test]
fn invalid_ad_tag_is_disabled_during_load() { fn invalid_ad_tag_is_disabled_during_load() {
let toml = r#" let toml = r#"
+81
View File
@@ -1367,6 +1367,82 @@ pub enum UnknownSniAction {
Mask, Mask,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TlsFetchProfile {
ModernChromeLike,
ModernFirefoxLike,
CompatTls12,
LegacyMinimal,
}
impl TlsFetchProfile {
pub fn as_str(self) -> &'static str {
match self {
TlsFetchProfile::ModernChromeLike => "modern_chrome_like",
TlsFetchProfile::ModernFirefoxLike => "modern_firefox_like",
TlsFetchProfile::CompatTls12 => "compat_tls12",
TlsFetchProfile::LegacyMinimal => "legacy_minimal",
}
}
}
fn default_tls_fetch_profiles() -> Vec<TlsFetchProfile> {
vec![
TlsFetchProfile::ModernChromeLike,
TlsFetchProfile::ModernFirefoxLike,
TlsFetchProfile::CompatTls12,
TlsFetchProfile::LegacyMinimal,
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsFetchConfig {
/// Ordered list of ClientHello profiles used for adaptive fallback.
#[serde(default = "default_tls_fetch_profiles")]
pub profiles: Vec<TlsFetchProfile>,
/// When true and upstream route is configured, TLS fetch fails closed on
/// upstream connect errors and does not fallback to direct TCP.
#[serde(default = "default_tls_fetch_strict_route")]
pub strict_route: bool,
/// Timeout per one profile attempt in milliseconds.
#[serde(default = "default_tls_fetch_attempt_timeout_ms")]
pub attempt_timeout_ms: u64,
/// Total wall-clock budget in milliseconds across all profile attempts.
#[serde(default = "default_tls_fetch_total_budget_ms")]
pub total_budget_ms: u64,
/// Adds GREASE-style values into selected ClientHello extensions.
#[serde(default)]
pub grease_enabled: bool,
/// Produces deterministic ClientHello randomness for debugging/tests.
#[serde(default)]
pub deterministic: bool,
/// TTL for winner-profile cache entries in seconds.
/// Set to 0 to disable profile cache.
#[serde(default = "default_tls_fetch_profile_cache_ttl_secs")]
pub profile_cache_ttl_secs: u64,
}
impl Default for TlsFetchConfig {
fn default() -> Self {
Self {
profiles: default_tls_fetch_profiles(),
strict_route: default_tls_fetch_strict_route(),
attempt_timeout_ms: default_tls_fetch_attempt_timeout_ms(),
total_budget_ms: default_tls_fetch_total_budget_ms(),
grease_enabled: false,
deterministic: false,
profile_cache_ttl_secs: default_tls_fetch_profile_cache_ttl_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AntiCensorshipConfig { pub struct AntiCensorshipConfig {
#[serde(default = "default_tls_domain")] #[serde(default = "default_tls_domain")]
@@ -1385,6 +1461,10 @@ pub struct AntiCensorshipConfig {
#[serde(default = "default_tls_fetch_scope")] #[serde(default = "default_tls_fetch_scope")]
pub tls_fetch_scope: String, pub tls_fetch_scope: String,
/// Fetch strategy for TLS front metadata bootstrap and periodic refresh.
#[serde(default)]
pub tls_fetch: TlsFetchConfig,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub mask: bool, pub mask: bool,
@@ -1492,6 +1572,7 @@ impl Default for AntiCensorshipConfig {
tls_domains: Vec::new(), tls_domains: Vec::new(),
unknown_sni_action: UnknownSniAction::Drop, unknown_sni_action: UnknownSniAction::Drop,
tls_fetch_scope: default_tls_fetch_scope(), tls_fetch_scope: default_tls_fetch_scope(),
tls_fetch: TlsFetchConfig::default(),
mask: default_true(), mask: default_true(),
mask_host: None, mask_host: None,
mask_port: default_mask_port(), mask_port: default_mask_port(),
+20 -5
View File
@@ -7,6 +7,7 @@ use tracing::warn;
use crate::config::ProxyConfig; use crate::config::ProxyConfig;
use crate::startup::{COMPONENT_TLS_FRONT_BOOTSTRAP, StartupTracker}; use crate::startup::{COMPONENT_TLS_FRONT_BOOTSTRAP, StartupTracker};
use crate::tls_front::TlsFrontCache; use crate::tls_front::TlsFrontCache;
use crate::tls_front::fetcher::TlsFetchStrategy;
use crate::transport::UpstreamManager; use crate::transport::UpstreamManager;
pub(crate) async fn bootstrap_tls_front( pub(crate) async fn bootstrap_tls_front(
@@ -40,7 +41,17 @@ pub(crate) async fn bootstrap_tls_front(
let mask_unix_sock = config.censorship.mask_unix_sock.clone(); let mask_unix_sock = config.censorship.mask_unix_sock.clone();
let tls_fetch_scope = (!config.censorship.tls_fetch_scope.is_empty()) let tls_fetch_scope = (!config.censorship.tls_fetch_scope.is_empty())
.then(|| config.censorship.tls_fetch_scope.clone()); .then(|| config.censorship.tls_fetch_scope.clone());
let fetch_timeout = Duration::from_secs(5); let tls_fetch = config.censorship.tls_fetch.clone();
let fetch_strategy = TlsFetchStrategy {
profiles: tls_fetch.profiles,
strict_route: tls_fetch.strict_route,
attempt_timeout: Duration::from_millis(tls_fetch.attempt_timeout_ms.max(1)),
total_budget: Duration::from_millis(tls_fetch.total_budget_ms.max(1)),
grease_enabled: tls_fetch.grease_enabled,
deterministic: tls_fetch.deterministic,
profile_cache_ttl: Duration::from_secs(tls_fetch.profile_cache_ttl_secs),
};
let fetch_timeout = fetch_strategy.total_budget;
let cache_initial = cache.clone(); let cache_initial = cache.clone();
let domains_initial = tls_domains.to_vec(); let domains_initial = tls_domains.to_vec();
@@ -48,6 +59,7 @@ pub(crate) async fn bootstrap_tls_front(
let unix_sock_initial = mask_unix_sock.clone(); let unix_sock_initial = mask_unix_sock.clone();
let scope_initial = tls_fetch_scope.clone(); let scope_initial = tls_fetch_scope.clone();
let upstream_initial = upstream_manager.clone(); let upstream_initial = upstream_manager.clone();
let strategy_initial = fetch_strategy.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut join = tokio::task::JoinSet::new(); let mut join = tokio::task::JoinSet::new();
for domain in domains_initial { for domain in domains_initial {
@@ -56,12 +68,13 @@ pub(crate) async fn bootstrap_tls_front(
let unix_sock_domain = unix_sock_initial.clone(); let unix_sock_domain = unix_sock_initial.clone();
let scope_domain = scope_initial.clone(); let scope_domain = scope_initial.clone();
let upstream_domain = upstream_initial.clone(); let upstream_domain = upstream_initial.clone();
let strategy_domain = strategy_initial.clone();
join.spawn(async move { join.spawn(async move {
match crate::tls_front::fetcher::fetch_real_tls( match crate::tls_front::fetcher::fetch_real_tls_with_strategy(
&host_domain, &host_domain,
port, port,
&domain, &domain,
fetch_timeout, &strategy_domain,
Some(upstream_domain), Some(upstream_domain),
scope_domain.as_deref(), scope_domain.as_deref(),
proxy_protocol, proxy_protocol,
@@ -107,6 +120,7 @@ pub(crate) async fn bootstrap_tls_front(
let unix_sock_refresh = mask_unix_sock.clone(); let unix_sock_refresh = mask_unix_sock.clone();
let scope_refresh = tls_fetch_scope.clone(); let scope_refresh = tls_fetch_scope.clone();
let upstream_refresh = upstream_manager.clone(); let upstream_refresh = upstream_manager.clone();
let strategy_refresh = fetch_strategy.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
let base_secs = rand::rng().random_range(4 * 3600..=6 * 3600); let base_secs = rand::rng().random_range(4 * 3600..=6 * 3600);
@@ -120,12 +134,13 @@ pub(crate) async fn bootstrap_tls_front(
let unix_sock_domain = unix_sock_refresh.clone(); let unix_sock_domain = unix_sock_refresh.clone();
let scope_domain = scope_refresh.clone(); let scope_domain = scope_refresh.clone();
let upstream_domain = upstream_refresh.clone(); let upstream_domain = upstream_refresh.clone();
let strategy_domain = strategy_refresh.clone();
join.spawn(async move { join.spawn(async move {
match crate::tls_front::fetcher::fetch_real_tls( match crate::tls_front::fetcher::fetch_real_tls_with_strategy(
&host_domain, &host_domain,
port, port,
&domain, &domain,
fetch_timeout, &strategy_domain,
Some(upstream_domain), Some(upstream_domain),
scope_domain.as_deref(), scope_domain.as_deref(),
proxy_protocol, proxy_protocol,
+1
View File
@@ -29,5 +29,6 @@ mod util;
#[tokio::main] #[tokio::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let _ = rustls::crypto::ring::default_provider().install_default();
maestro::run().await maestro::run().await
} }
+127 -53
View File
@@ -13,7 +13,7 @@ use std::sync::Arc;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tracing::{debug, trace, warn}; use tracing::{debug, info, trace, warn};
use zeroize::{Zeroize, Zeroizing}; use zeroize::{Zeroize, Zeroizing};
use crate::config::{ProxyConfig, UnknownSniAction}; use crate::config::{ProxyConfig, UnknownSniAction};
@@ -28,6 +28,8 @@ use rand::RngExt;
const ACCESS_SECRET_BYTES: usize = 16; const ACCESS_SECRET_BYTES: usize = 16;
static INVALID_SECRET_WARNED: OnceLock<Mutex<HashSet<(String, String)>>> = OnceLock::new(); static INVALID_SECRET_WARNED: OnceLock<Mutex<HashSet<(String, String)>>> = OnceLock::new();
const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5;
static UNKNOWN_SNI_WARN_NEXT_ALLOWED: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
#[cfg(test)] #[cfg(test)]
const WARNED_SECRET_MAX_ENTRIES: usize = 64; const WARNED_SECRET_MAX_ENTRIES: usize = 64;
#[cfg(not(test))] #[cfg(not(test))]
@@ -86,6 +88,24 @@ fn auth_probe_saturation_state_lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
} }
fn unknown_sni_warn_state_lock() -> std::sync::MutexGuard<'static, Option<Instant>> {
UNKNOWN_SNI_WARN_NEXT_ALLOWED
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn should_emit_unknown_sni_warn(now: Instant) -> bool {
let mut guard = unknown_sni_warn_state_lock();
if let Some(next_allowed) = *guard
&& now < next_allowed
{
return false;
}
*guard = Some(now + Duration::from_secs(UNKNOWN_SNI_WARN_COOLDOWN_SECS));
true
}
fn normalize_auth_probe_ip(peer_ip: IpAddr) -> IpAddr { fn normalize_auth_probe_ip(peer_ip: IpAddr) -> IpAddr {
match peer_ip { match peer_ip {
IpAddr::V4(ip) => IpAddr::V4(ip), IpAddr::V4(ip) => IpAddr::V4(ip),
@@ -282,30 +302,9 @@ fn auth_probe_record_failure_with_state(
let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None; let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None;
let state_len = state.len(); let state_len = state.len();
let scan_limit = state_len.min(AUTH_PROBE_PRUNE_SCAN_LIMIT); let scan_limit = state_len.min(AUTH_PROBE_PRUNE_SCAN_LIMIT);
let start_offset = auth_probe_scan_start_offset(peer_ip, now, state_len, scan_limit);
let mut scanned = 0usize; if state_len <= AUTH_PROBE_PRUNE_SCAN_LIMIT {
for entry in state.iter().skip(start_offset) { for entry in state.iter() {
let key = *entry.key();
let fail_streak = entry.value().fail_streak;
let last_seen = entry.value().last_seen;
match eviction_candidate {
Some((_, current_fail, current_seen))
if fail_streak > current_fail
|| (fail_streak == current_fail && last_seen >= current_seen) => {}
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
}
if auth_probe_state_expired(entry.value(), now) {
stale_keys.push(key);
}
scanned += 1;
if scanned >= scan_limit {
break;
}
}
if scanned < scan_limit {
for entry in state.iter().take(scan_limit - scanned) {
let key = *entry.key(); let key = *entry.key();
let fail_streak = entry.value().fail_streak; let fail_streak = entry.value().fail_streak;
let last_seen = entry.value().last_seen; let last_seen = entry.value().last_seen;
@@ -319,6 +318,46 @@ fn auth_probe_record_failure_with_state(
stale_keys.push(key); stale_keys.push(key);
} }
} }
} else {
let start_offset =
auth_probe_scan_start_offset(peer_ip, now, state_len, scan_limit);
let mut scanned = 0usize;
for entry in state.iter().skip(start_offset) {
let key = *entry.key();
let fail_streak = entry.value().fail_streak;
let last_seen = entry.value().last_seen;
match eviction_candidate {
Some((_, current_fail, current_seen))
if fail_streak > current_fail
|| (fail_streak == current_fail && last_seen >= current_seen) => {}
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
}
if auth_probe_state_expired(entry.value(), now) {
stale_keys.push(key);
}
scanned += 1;
if scanned >= scan_limit {
break;
}
}
if scanned < scan_limit {
for entry in state.iter().take(scan_limit - scanned) {
let key = *entry.key();
let fail_streak = entry.value().fail_streak;
let last_seen = entry.value().last_seen;
match eviction_candidate {
Some((_, current_fail, current_seen))
if fail_streak > current_fail
|| (fail_streak == current_fail
&& last_seen >= current_seen) => {}
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
}
if auth_probe_state_expired(entry.value(), now) {
stale_keys.push(key);
}
}
}
} }
for stale_key in stale_keys { for stale_key in stale_keys {
@@ -393,6 +432,25 @@ fn auth_probe_test_lock() -> &'static Mutex<()> {
TEST_LOCK.get_or_init(|| Mutex::new(())) TEST_LOCK.get_or_init(|| Mutex::new(()))
} }
#[cfg(test)]
fn unknown_sni_warn_test_lock() -> &'static Mutex<()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK.get_or_init(|| Mutex::new(()))
}
#[cfg(test)]
fn clear_unknown_sni_warn_state_for_testing() {
if UNKNOWN_SNI_WARN_NEXT_ALLOWED.get().is_some() {
let mut guard = unknown_sni_warn_state_lock();
*guard = None;
}
}
#[cfg(test)]
fn should_emit_unknown_sni_warn_for_testing(now: Instant) -> bool {
should_emit_unknown_sni_warn(now)
}
#[cfg(test)] #[cfg(test)]
fn clear_warned_secrets_for_testing() { fn clear_warned_secrets_for_testing() {
if let Some(warned) = INVALID_SECRET_WARNED.get() if let Some(warned) = INVALID_SECRET_WARNED.get()
@@ -608,26 +666,63 @@ where
} }
let client_sni = tls::extract_sni_from_client_hello(handshake); let client_sni = tls::extract_sni_from_client_hello(handshake);
let preferred_user_hint = client_sni
.as_deref()
.filter(|sni| config.access.users.contains_key(*sni));
let matched_tls_domain = client_sni let matched_tls_domain = client_sni
.as_deref() .as_deref()
.and_then(|sni| find_matching_tls_domain(config, sni)); .and_then(|sni| find_matching_tls_domain(config, sni));
if client_sni.is_some() && matched_tls_domain.is_none() { let alpn_list = if config.censorship.alpn_enforce {
tls::extract_alpn_from_client_hello(handshake)
} else {
Vec::new()
};
let selected_alpn = if config.censorship.alpn_enforce {
if alpn_list.iter().any(|p| p == b"h2") {
Some(b"h2".to_vec())
} else if alpn_list.iter().any(|p| p == b"http/1.1") {
Some(b"http/1.1".to_vec())
} else if !alpn_list.is_empty() {
maybe_apply_server_hello_delay(config).await;
debug!(peer = %peer, "Client ALPN list has no supported protocol; using masking fallback");
return HandshakeResult::BadClient { reader, writer };
} else {
None
}
} else {
None
};
if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() {
auth_probe_record_failure(peer.ip(), Instant::now()); auth_probe_record_failure(peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await; maybe_apply_server_hello_delay(config).await;
debug!( let sni = client_sni.as_deref().unwrap_or_default();
peer = %peer, let log_now = Instant::now();
sni = ?client_sni, if should_emit_unknown_sni_warn(log_now) {
action = ?config.censorship.unknown_sni_action, warn!(
"TLS handshake rejected by unknown SNI policy" peer = %peer,
); sni = %sni,
unknown_sni = true,
unknown_sni_action = ?config.censorship.unknown_sni_action,
"TLS handshake rejected by unknown SNI policy"
);
} else {
info!(
peer = %peer,
sni = %sni,
unknown_sni = true,
unknown_sni_action = ?config.censorship.unknown_sni_action,
"TLS handshake rejected by unknown SNI policy"
);
}
return match config.censorship.unknown_sni_action { return match config.censorship.unknown_sni_action {
UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni), UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni),
UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer },
}; };
} }
let secrets = decode_user_secrets(config, client_sni.as_deref()); let secrets = decode_user_secrets(config, preferred_user_hint);
let validation = match tls::validate_tls_handshake_with_replay_window( let validation = match tls::validate_tls_handshake_with_replay_window(
handshake, handshake,
@@ -684,27 +779,6 @@ where
None None
}; };
let alpn_list = if config.censorship.alpn_enforce {
tls::extract_alpn_from_client_hello(handshake)
} else {
Vec::new()
};
let selected_alpn = if config.censorship.alpn_enforce {
if alpn_list.iter().any(|p| p == b"h2") {
Some(b"h2".to_vec())
} else if alpn_list.iter().any(|p| p == b"http/1.1") {
Some(b"http/1.1".to_vec())
} else if !alpn_list.is_empty() {
maybe_apply_server_hello_delay(config).await;
debug!(peer = %peer, "Client ALPN list has no supported protocol; using masking fallback");
return HandshakeResult::BadClient { reader, writer };
} else {
None
}
} else {
None
};
// Add replay digest only for policy-valid handshakes. // Add replay digest only for policy-valid handshakes.
replay_checker.add_tls_digest(digest_half); replay_checker.add_tls_digest(digest_half);
@@ -1643,6 +1643,32 @@ fn auth_probe_capacity_fresh_full_map_still_tracks_newcomer_with_bounded_evictio
); );
} }
#[test]
fn unknown_sni_warn_cooldown_first_event_is_warn_and_repeated_events_are_info_until_window_expires()
{
let _guard = unknown_sni_warn_test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_unknown_sni_warn_state_for_testing();
let now = Instant::now();
assert!(
should_emit_unknown_sni_warn_for_testing(now),
"first unknown SNI event must be eligible for WARN emission"
);
assert!(
!should_emit_unknown_sni_warn_for_testing(now + Duration::from_secs(1)),
"events inside cooldown window must be demoted from WARN to INFO"
);
assert!(
should_emit_unknown_sni_warn_for_testing(
now + Duration::from_secs(UNKNOWN_SNI_WARN_COOLDOWN_SECS)
),
"once cooldown expires, next unknown SNI event must be WARN-eligible again"
);
}
#[test] #[test]
fn stress_auth_probe_full_map_churn_keeps_bound_and_tracks_newcomers() { fn stress_auth_probe_full_map_churn_keeps_bound_and_tracks_newcomers() {
let _guard = auth_probe_test_lock() let _guard = auth_probe_test_lock()
+672 -108
View File
@@ -1,7 +1,9 @@
#![allow(clippy::too_many_arguments)] #![allow(clippy::too_many_arguments)]
use dashmap::DashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::sync::OnceLock;
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
@@ -21,7 +23,8 @@ use rustls::{DigitallySignedStruct, Error as RustlsError};
use x509_parser::certificate::X509Certificate; use x509_parser::certificate::X509Certificate;
use x509_parser::prelude::FromDer; use x509_parser::prelude::FromDer;
use crate::crypto::SecureRandom; use crate::config::TlsFetchProfile;
use crate::crypto::{SecureRandom, sha256};
use crate::network::dns_overrides::resolve_socket_addr; use crate::network::dns_overrides::resolve_socket_addr;
use crate::protocol::constants::{ use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
@@ -78,6 +81,200 @@ impl ServerCertVerifier for NoVerify {
} }
} }
#[derive(Debug, Clone)]
pub struct TlsFetchStrategy {
pub profiles: Vec<TlsFetchProfile>,
pub strict_route: bool,
pub attempt_timeout: Duration,
pub total_budget: Duration,
pub grease_enabled: bool,
pub deterministic: bool,
pub profile_cache_ttl: Duration,
}
impl TlsFetchStrategy {
#[allow(dead_code)]
pub fn single_attempt(connect_timeout: Duration) -> Self {
Self {
profiles: vec![TlsFetchProfile::CompatTls12],
strict_route: false,
attempt_timeout: connect_timeout.max(Duration::from_millis(1)),
total_budget: connect_timeout.max(Duration::from_millis(1)),
grease_enabled: false,
deterministic: false,
profile_cache_ttl: Duration::ZERO,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ProfileCacheKey {
host: String,
port: u16,
sni: String,
scope: Option<String>,
proxy_protocol: u8,
route_hint: RouteHint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum RouteHint {
Direct,
Upstream,
Unix,
}
#[derive(Debug, Clone, Copy)]
struct ProfileCacheValue {
profile: TlsFetchProfile,
updated_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FetchErrorKind {
Connect,
Route,
EarlyEof,
Timeout,
ServerHelloMissing,
TlsAlert,
Parse,
Other,
}
static PROFILE_CACHE: OnceLock<DashMap<ProfileCacheKey, ProfileCacheValue>> = OnceLock::new();
fn profile_cache() -> &'static DashMap<ProfileCacheKey, ProfileCacheValue> {
PROFILE_CACHE.get_or_init(DashMap::new)
}
fn route_hint(
upstream: Option<&std::sync::Arc<crate::transport::UpstreamManager>>,
unix_sock: Option<&str>,
) -> RouteHint {
if unix_sock.is_some() {
RouteHint::Unix
} else if upstream.is_some() {
RouteHint::Upstream
} else {
RouteHint::Direct
}
}
fn profile_cache_key(
host: &str,
port: u16,
sni: &str,
upstream: Option<&std::sync::Arc<crate::transport::UpstreamManager>>,
scope: Option<&str>,
proxy_protocol: u8,
unix_sock: Option<&str>,
) -> ProfileCacheKey {
ProfileCacheKey {
host: host.to_string(),
port,
sni: sni.to_string(),
scope: scope.map(ToString::to_string),
proxy_protocol,
route_hint: route_hint(upstream, unix_sock),
}
}
fn classify_fetch_error(err: &anyhow::Error) -> FetchErrorKind {
for cause in err.chain() {
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return match io.kind() {
std::io::ErrorKind::TimedOut => FetchErrorKind::Timeout,
std::io::ErrorKind::UnexpectedEof => FetchErrorKind::EarlyEof,
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::NotConnected
| std::io::ErrorKind::AddrNotAvailable => FetchErrorKind::Connect,
_ => FetchErrorKind::Other,
};
}
}
let message = err.to_string().to_lowercase();
if message.contains("upstream route") {
FetchErrorKind::Route
} else if message.contains("serverhello not received") {
FetchErrorKind::ServerHelloMissing
} else if message.contains("alert") {
FetchErrorKind::TlsAlert
} else if message.contains("parse") {
FetchErrorKind::Parse
} else if message.contains("timed out") || message.contains("deadline has elapsed") {
FetchErrorKind::Timeout
} else if message.contains("eof") {
FetchErrorKind::EarlyEof
} else {
FetchErrorKind::Other
}
}
fn order_profiles(
strategy: &TlsFetchStrategy,
cache_key: Option<&ProfileCacheKey>,
now: Instant,
) -> Vec<TlsFetchProfile> {
let mut ordered = if strategy.profiles.is_empty() {
vec![TlsFetchProfile::CompatTls12]
} else {
strategy.profiles.clone()
};
if strategy.profile_cache_ttl.is_zero() {
return ordered;
}
let Some(key) = cache_key else {
return ordered;
};
if let Some(cached) = profile_cache().get(key) {
let age = now.saturating_duration_since(cached.updated_at);
if age > strategy.profile_cache_ttl {
drop(cached);
profile_cache().remove(key);
return ordered;
}
if let Some(pos) = ordered
.iter()
.position(|profile| *profile == cached.profile)
{
if pos != 0 {
ordered.swap(0, pos);
}
}
}
ordered
}
fn remember_profile_success(
strategy: &TlsFetchStrategy,
cache_key: Option<ProfileCacheKey>,
profile: TlsFetchProfile,
now: Instant,
) {
if strategy.profile_cache_ttl.is_zero() {
return;
}
let Some(key) = cache_key else {
return;
};
profile_cache().insert(
key,
ProfileCacheValue {
profile,
updated_at: now,
},
);
}
fn build_client_config() -> Arc<ClientConfig> { fn build_client_config() -> Arc<ClientConfig> {
let root = rustls::RootCertStore::empty(); let root = rustls::RootCertStore::empty();
@@ -95,7 +292,114 @@ fn build_client_config() -> Arc<ClientConfig> {
Arc::new(config) Arc::new(config)
} }
fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> { fn deterministic_bytes(seed: &str, len: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(len);
let mut counter: u32 = 0;
while out.len() < len {
let mut chunk_seed = Vec::with_capacity(seed.len() + std::mem::size_of::<u32>());
chunk_seed.extend_from_slice(seed.as_bytes());
chunk_seed.extend_from_slice(&counter.to_le_bytes());
out.extend_from_slice(&sha256(&chunk_seed));
counter = counter.wrapping_add(1);
}
out.truncate(len);
out
}
fn profile_cipher_suites(profile: TlsFetchProfile) -> &'static [u16] {
const MODERN_CHROME: &[u16] = &[
0x1301, 0x1302, 0x1303, 0xc02b, 0xc02c, 0xcca9, 0xc02f, 0xc030, 0xcca8, 0x009e, 0x00ff,
];
const MODERN_FIREFOX: &[u16] = &[
0x1301, 0x1303, 0x1302, 0xc02b, 0xcca9, 0xc02c, 0xc02f, 0xcca8, 0xc030, 0x009e, 0x00ff,
];
const COMPAT_TLS12: &[u16] = &[
0xc02b, 0xc02c, 0xc02f, 0xc030, 0xcca9, 0xcca8, 0x1301, 0x1302, 0x1303, 0x009e, 0x00ff,
];
const LEGACY_MINIMAL: &[u16] = &[0xc02b, 0xc02f, 0x1301, 0x1302, 0x00ff];
match profile {
TlsFetchProfile::ModernChromeLike => MODERN_CHROME,
TlsFetchProfile::ModernFirefoxLike => MODERN_FIREFOX,
TlsFetchProfile::CompatTls12 => COMPAT_TLS12,
TlsFetchProfile::LegacyMinimal => LEGACY_MINIMAL,
}
}
fn profile_groups(profile: TlsFetchProfile) -> &'static [u16] {
const MODERN: &[u16] = &[0x001d, 0x0017, 0x0018]; // x25519, secp256r1, secp384r1
const COMPAT: &[u16] = &[0x001d, 0x0017];
const LEGACY: &[u16] = &[0x0017];
match profile {
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => MODERN,
TlsFetchProfile::CompatTls12 => COMPAT,
TlsFetchProfile::LegacyMinimal => LEGACY,
}
}
fn profile_sig_algs(profile: TlsFetchProfile) -> &'static [u16] {
const MODERN: &[u16] = &[0x0804, 0x0805, 0x0403, 0x0503, 0x0806];
const COMPAT: &[u16] = &[0x0403, 0x0503, 0x0804, 0x0805];
const LEGACY: &[u16] = &[0x0403, 0x0804];
match profile {
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => MODERN,
TlsFetchProfile::CompatTls12 => COMPAT,
TlsFetchProfile::LegacyMinimal => LEGACY,
}
}
fn profile_alpn(profile: TlsFetchProfile) -> &'static [&'static [u8]] {
const H2_HTTP11: &[&[u8]] = &[b"h2", b"http/1.1"];
const HTTP11: &[&[u8]] = &[b"http/1.1"];
match profile {
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => H2_HTTP11,
TlsFetchProfile::CompatTls12 | TlsFetchProfile::LegacyMinimal => HTTP11,
}
}
fn profile_supported_versions(profile: TlsFetchProfile) -> &'static [u16] {
const MODERN: &[u16] = &[0x0304, 0x0303];
const COMPAT: &[u16] = &[0x0303, 0x0304];
const LEGACY: &[u16] = &[0x0303];
match profile {
TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => MODERN,
TlsFetchProfile::CompatTls12 => COMPAT,
TlsFetchProfile::LegacyMinimal => LEGACY,
}
}
fn profile_padding_target(profile: TlsFetchProfile) -> usize {
match profile {
TlsFetchProfile::ModernChromeLike => 220,
TlsFetchProfile::ModernFirefoxLike => 200,
TlsFetchProfile::CompatTls12 => 180,
TlsFetchProfile::LegacyMinimal => 64,
}
}
fn grease_value(rng: &SecureRandom, deterministic: bool, seed: &str) -> u16 {
const GREASE_VALUES: [u16; 16] = [
0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa,
0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa,
];
if deterministic {
let idx = deterministic_bytes(seed, 1)[0] as usize % GREASE_VALUES.len();
GREASE_VALUES[idx]
} else {
let idx = (rng.bytes(1)[0] as usize) % GREASE_VALUES.len();
GREASE_VALUES[idx]
}
}
fn build_client_hello(
sni: &str,
rng: &SecureRandom,
profile: TlsFetchProfile,
grease_enabled: bool,
deterministic: bool,
) -> Vec<u8> {
// === ClientHello body === // === ClientHello body ===
let mut body = Vec::new(); let mut body = Vec::new();
@@ -103,29 +407,20 @@ fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> {
body.extend_from_slice(&[0x03, 0x03]); body.extend_from_slice(&[0x03, 0x03]);
// Random // Random
body.extend_from_slice(&rng.bytes(32)); if deterministic {
body.extend_from_slice(&deterministic_bytes(&format!("tls-fetch-random:{sni}"), 32));
} else {
body.extend_from_slice(&rng.bytes(32));
}
// Session ID: empty // Session ID: empty
body.push(0); body.push(0);
// Cipher suites: let mut cipher_suites = profile_cipher_suites(profile).to_vec();
// - TLS1.3 set if grease_enabled {
// - broad TLS1.2 ECDHE set for RSA/ECDSA cert chains let grease = grease_value(rng, deterministic, &format!("cipher:{sni}"));
// This keeps raw probing compatible with common production frontends that cipher_suites.insert(0, grease);
// still negotiate TLS1.2. }
let cipher_suites: [u16; 11] = [
0x1301, // TLS_AES_128_GCM_SHA256
0x1302, // TLS_AES_256_GCM_SHA384
0x1303, // TLS_CHACHA20_POLY1305_SHA256
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
];
body.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes()); body.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes());
for suite in cipher_suites { for suite in cipher_suites {
body.extend_from_slice(&suite.to_be_bytes()); body.extend_from_slice(&suite.to_be_bytes());
@@ -150,7 +445,11 @@ fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> {
exts.extend_from_slice(&sni_ext); exts.extend_from_slice(&sni_ext);
// supported_groups // supported_groups
let groups: [u16; 2] = [0x001d, 0x0017]; // x25519, secp256r1 let mut groups = profile_groups(profile).to_vec();
if grease_enabled {
let grease = grease_value(rng, deterministic, &format!("group:{sni}"));
groups.insert(0, grease);
}
exts.extend_from_slice(&0x000au16.to_be_bytes()); exts.extend_from_slice(&0x000au16.to_be_bytes());
exts.extend_from_slice(&((2 + groups.len() * 2) as u16).to_be_bytes()); exts.extend_from_slice(&((2 + groups.len() * 2) as u16).to_be_bytes());
exts.extend_from_slice(&(groups.len() as u16 * 2).to_be_bytes()); exts.extend_from_slice(&(groups.len() as u16 * 2).to_be_bytes());
@@ -159,7 +458,11 @@ fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> {
} }
// signature_algorithms // signature_algorithms
let sig_algs: [u16; 4] = [0x0804, 0x0805, 0x0403, 0x0503]; // rsa_pss_rsae_sha256/384, ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384 let mut sig_algs = profile_sig_algs(profile).to_vec();
if grease_enabled {
let grease = grease_value(rng, deterministic, &format!("sigalg:{sni}"));
sig_algs.insert(0, grease);
}
exts.extend_from_slice(&0x000du16.to_be_bytes()); exts.extend_from_slice(&0x000du16.to_be_bytes());
exts.extend_from_slice(&((2 + sig_algs.len() * 2) as u16).to_be_bytes()); exts.extend_from_slice(&((2 + sig_algs.len() * 2) as u16).to_be_bytes());
exts.extend_from_slice(&(sig_algs.len() as u16 * 2).to_be_bytes()); exts.extend_from_slice(&(sig_algs.len() as u16 * 2).to_be_bytes());
@@ -167,8 +470,12 @@ fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> {
exts.extend_from_slice(&a.to_be_bytes()); exts.extend_from_slice(&a.to_be_bytes());
} }
// supported_versions (TLS1.3 + TLS1.2) // supported_versions
let versions: [u16; 2] = [0x0304, 0x0303]; let mut versions = profile_supported_versions(profile).to_vec();
if grease_enabled {
let grease = grease_value(rng, deterministic, &format!("version:{sni}"));
versions.insert(0, grease);
}
exts.extend_from_slice(&0x002bu16.to_be_bytes()); exts.extend_from_slice(&0x002bu16.to_be_bytes());
exts.extend_from_slice(&((1 + versions.len() * 2) as u16).to_be_bytes()); exts.extend_from_slice(&((1 + versions.len() * 2) as u16).to_be_bytes());
exts.push((versions.len() * 2) as u8); exts.push((versions.len() * 2) as u8);
@@ -177,7 +484,14 @@ fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> {
} }
// key_share (x25519) // key_share (x25519)
let key = gen_key_share(rng); let key = if deterministic {
let det = deterministic_bytes(&format!("keyshare:{sni}"), 32);
let mut key = [0u8; 32];
key.copy_from_slice(&det);
key
} else {
gen_key_share(rng)
};
let mut keyshare = Vec::with_capacity(4 + key.len()); let mut keyshare = Vec::with_capacity(4 + key.len());
keyshare.extend_from_slice(&0x001du16.to_be_bytes()); // group keyshare.extend_from_slice(&0x001du16.to_be_bytes()); // group
keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes()); keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes());
@@ -187,18 +501,29 @@ fn build_client_hello(sni: &str, rng: &SecureRandom) -> Vec<u8> {
exts.extend_from_slice(&(keyshare.len() as u16).to_be_bytes()); exts.extend_from_slice(&(keyshare.len() as u16).to_be_bytes());
exts.extend_from_slice(&keyshare); exts.extend_from_slice(&keyshare);
// ALPN (http/1.1) // ALPN
let alpn_proto = b"http/1.1"; let mut alpn_list = Vec::new();
exts.extend_from_slice(&0x0010u16.to_be_bytes()); for proto in profile_alpn(profile) {
exts.extend_from_slice(&((2 + 1 + alpn_proto.len()) as u16).to_be_bytes()); alpn_list.push(proto.len() as u8);
exts.extend_from_slice(&((1 + alpn_proto.len()) as u16).to_be_bytes()); alpn_list.extend_from_slice(proto);
exts.push(alpn_proto.len() as u8); }
exts.extend_from_slice(alpn_proto); if !alpn_list.is_empty() {
exts.extend_from_slice(&0x0010u16.to_be_bytes());
exts.extend_from_slice(&((2 + alpn_list.len()) as u16).to_be_bytes());
exts.extend_from_slice(&(alpn_list.len() as u16).to_be_bytes());
exts.extend_from_slice(&alpn_list);
}
if grease_enabled {
let grease = grease_value(rng, deterministic, &format!("ext:{sni}"));
exts.extend_from_slice(&grease.to_be_bytes());
exts.extend_from_slice(&0u16.to_be_bytes());
}
// padding to reduce recognizability and keep length ~500 bytes // padding to reduce recognizability and keep length ~500 bytes
const TARGET_EXT_LEN: usize = 180; let target_ext_len = profile_padding_target(profile);
if exts.len() < TARGET_EXT_LEN { if exts.len() < target_ext_len {
let remaining = TARGET_EXT_LEN - exts.len(); let remaining = target_ext_len - exts.len();
if remaining > 4 { if remaining > 4 {
let pad_len = remaining - 4; // minus type+len let pad_len = remaining - 4; // minus type+len
exts.extend_from_slice(&0x0015u16.to_be_bytes()); // padding extension exts.extend_from_slice(&0x0015u16.to_be_bytes()); // padding extension
@@ -414,27 +739,41 @@ async fn connect_tcp_with_upstream(
connect_timeout: Duration, connect_timeout: Duration,
upstream: Option<std::sync::Arc<crate::transport::UpstreamManager>>, upstream: Option<std::sync::Arc<crate::transport::UpstreamManager>>,
scope: Option<&str>, scope: Option<&str>,
strict_route: bool,
) -> Result<UpstreamStream> { ) -> Result<UpstreamStream> {
if let Some(manager) = upstream { if let Some(manager) = upstream {
if let Some(addr) = resolve_socket_addr(host, port) { let resolved = if let Some(addr) = resolve_socket_addr(host, port) {
match manager.connect(addr, None, scope).await { Some(addr)
Ok(stream) => return Ok(stream), } else {
Err(e) => { match tokio::net::lookup_host((host, port)).await {
warn!( Ok(mut addrs) => addrs.find(|a| a.is_ipv4()),
host = %host, Err(e) => {
port = port, if strict_route {
scope = ?scope, return Err(anyhow!(
error = %e, "upstream route DNS resolution failed for {host}:{port}: {e}"
"Upstream connect failed, using direct connect" ));
); }
} warn!(
} host = %host,
} else if let Ok(mut addrs) = tokio::net::lookup_host((host, port)).await port = port,
&& let Some(addr) = addrs.find(|a| a.is_ipv4()) scope = ?scope,
{ error = %e,
"Upstream DNS resolution failed, using direct connect"
);
None
}
}
};
if let Some(addr) = resolved {
match manager.connect(addr, None, scope).await { match manager.connect(addr, None, scope).await {
Ok(stream) => return Ok(stream), Ok(stream) => return Ok(stream),
Err(e) => { Err(e) => {
if strict_route {
return Err(anyhow!(
"upstream route connect failed for {host}:{port}: {e}"
));
}
warn!( warn!(
host = %host, host = %host,
port = port, port = port,
@@ -444,6 +783,10 @@ async fn connect_tcp_with_upstream(
); );
} }
} }
} else if strict_route {
return Err(anyhow!(
"upstream route resolution produced no usable address for {host}:{port}"
));
} }
} }
Ok(UpstreamStream::Tcp( Ok(UpstreamStream::Tcp(
@@ -483,12 +826,15 @@ async fn fetch_via_raw_tls_stream<S>(
sni: &str, sni: &str,
connect_timeout: Duration, connect_timeout: Duration,
proxy_protocol: u8, proxy_protocol: u8,
profile: TlsFetchProfile,
grease_enabled: bool,
deterministic: bool,
) -> Result<TlsFetchResult> ) -> Result<TlsFetchResult>
where where
S: AsyncRead + AsyncWrite + Unpin, S: AsyncRead + AsyncWrite + Unpin,
{ {
let rng = SecureRandom::new(); let rng = SecureRandom::new();
let client_hello = build_client_hello(sni, &rng); let client_hello = build_client_hello(sni, &rng, profile, grease_enabled, deterministic);
timeout(connect_timeout, async { timeout(connect_timeout, async {
if proxy_protocol > 0 { if proxy_protocol > 0 {
let header = match proxy_protocol { let header = match proxy_protocol {
@@ -562,6 +908,10 @@ async fn fetch_via_raw_tls(
scope: Option<&str>, scope: Option<&str>,
proxy_protocol: u8, proxy_protocol: u8,
unix_sock: Option<&str>, unix_sock: Option<&str>,
strict_route: bool,
profile: TlsFetchProfile,
grease_enabled: bool,
deterministic: bool,
) -> Result<TlsFetchResult> { ) -> Result<TlsFetchResult> {
#[cfg(unix)] #[cfg(unix)]
if let Some(sock_path) = unix_sock { if let Some(sock_path) = unix_sock {
@@ -572,8 +922,16 @@ async fn fetch_via_raw_tls(
sock = %sock_path, sock = %sock_path,
"Raw TLS fetch using mask unix socket" "Raw TLS fetch using mask unix socket"
); );
return fetch_via_raw_tls_stream(stream, sni, connect_timeout, proxy_protocol) return fetch_via_raw_tls_stream(
.await; stream,
sni,
connect_timeout,
proxy_protocol,
profile,
grease_enabled,
deterministic,
)
.await;
} }
Ok(Err(e)) => { Ok(Err(e)) => {
warn!( warn!(
@@ -596,8 +954,19 @@ async fn fetch_via_raw_tls(
#[cfg(not(unix))] #[cfg(not(unix))]
let _ = unix_sock; let _ = unix_sock;
let stream = connect_tcp_with_upstream(host, port, connect_timeout, upstream, scope).await?; let stream =
fetch_via_raw_tls_stream(stream, sni, connect_timeout, proxy_protocol).await connect_tcp_with_upstream(host, port, connect_timeout, upstream, scope, strict_route)
.await?;
fetch_via_raw_tls_stream(
stream,
sni,
connect_timeout,
proxy_protocol,
profile,
grease_enabled,
deterministic,
)
.await
} }
async fn fetch_via_rustls_stream<S>( async fn fetch_via_rustls_stream<S>(
@@ -703,6 +1072,7 @@ async fn fetch_via_rustls(
scope: Option<&str>, scope: Option<&str>,
proxy_protocol: u8, proxy_protocol: u8,
unix_sock: Option<&str>, unix_sock: Option<&str>,
strict_route: bool,
) -> Result<TlsFetchResult> { ) -> Result<TlsFetchResult> {
#[cfg(unix)] #[cfg(unix)]
if let Some(sock_path) = unix_sock { if let Some(sock_path) = unix_sock {
@@ -736,16 +1106,153 @@ async fn fetch_via_rustls(
#[cfg(not(unix))] #[cfg(not(unix))]
let _ = unix_sock; let _ = unix_sock;
let stream = connect_tcp_with_upstream(host, port, connect_timeout, upstream, scope).await?; let stream =
connect_tcp_with_upstream(host, port, connect_timeout, upstream, scope, strict_route)
.await?;
fetch_via_rustls_stream(stream, host, sni, proxy_protocol).await fetch_via_rustls_stream(stream, host, sni, proxy_protocol).await
} }
/// Fetch real TLS metadata for the given SNI. /// Fetch real TLS metadata with an adaptive multi-profile strategy.
/// pub async fn fetch_real_tls_with_strategy(
/// Strategy: host: &str,
/// 1) Probe raw TLS for realistic ServerHello and ApplicationData record sizes. port: u16,
/// 2) Fetch certificate chain via rustls to build cert payload. sni: &str,
/// 3) Merge both when possible; otherwise auto-fallback to whichever succeeded. strategy: &TlsFetchStrategy,
upstream: Option<std::sync::Arc<crate::transport::UpstreamManager>>,
scope: Option<&str>,
proxy_protocol: u8,
unix_sock: Option<&str>,
) -> Result<TlsFetchResult> {
let attempt_timeout = strategy.attempt_timeout.max(Duration::from_millis(1));
let total_budget = strategy.total_budget.max(Duration::from_millis(1));
let started_at = Instant::now();
let cache_key = profile_cache_key(
host,
port,
sni,
upstream.as_ref(),
scope,
proxy_protocol,
unix_sock,
);
let profiles = order_profiles(strategy, Some(&cache_key), started_at);
let mut raw_result = None;
let mut raw_last_error: Option<anyhow::Error> = None;
let mut raw_last_error_kind = FetchErrorKind::Other;
let mut selected_profile = None;
for profile in profiles {
let elapsed = started_at.elapsed();
if elapsed >= total_budget {
break;
}
let timeout_for_attempt = attempt_timeout.min(total_budget - elapsed);
match fetch_via_raw_tls(
host,
port,
sni,
timeout_for_attempt,
upstream.clone(),
scope,
proxy_protocol,
unix_sock,
strategy.strict_route,
profile,
strategy.grease_enabled,
strategy.deterministic,
)
.await
{
Ok(res) => {
selected_profile = Some(profile);
raw_result = Some(res);
break;
}
Err(err) => {
let kind = classify_fetch_error(&err);
warn!(
sni = %sni,
profile = profile.as_str(),
error_kind = ?kind,
error = %err,
"Raw TLS fetch attempt failed"
);
raw_last_error_kind = kind;
raw_last_error = Some(err);
if strategy.strict_route && matches!(kind, FetchErrorKind::Route) {
break;
}
}
}
}
if let Some(profile) = selected_profile {
remember_profile_success(strategy, Some(cache_key), profile, Instant::now());
}
if raw_result.is_none()
&& strategy.strict_route
&& matches!(raw_last_error_kind, FetchErrorKind::Route)
{
if let Some(err) = raw_last_error {
return Err(err);
}
return Err(anyhow!("TLS fetch strict-route failure"));
}
let elapsed = started_at.elapsed();
if elapsed >= total_budget {
return match raw_result {
Some(raw) => Ok(raw),
None => {
Err(raw_last_error.unwrap_or_else(|| anyhow!("TLS fetch total budget exhausted")))
}
};
}
let rustls_timeout = attempt_timeout.min(total_budget - elapsed);
let rustls_result = fetch_via_rustls(
host,
port,
sni,
rustls_timeout,
upstream,
scope,
proxy_protocol,
unix_sock,
strategy.strict_route,
)
.await;
match rustls_result {
Ok(rustls) => {
if let Some(mut raw) = raw_result {
raw.cert_info = rustls.cert_info;
raw.cert_payload = rustls.cert_payload;
raw.behavior_profile.source = TlsProfileSource::Merged;
debug!(sni = %sni, "Fetched TLS metadata via adaptive raw probe + rustls cert chain");
Ok(raw)
} else {
Ok(rustls)
}
}
Err(err) => {
if let Some(raw) = raw_result {
warn!(sni = %sni, error = %err, "Rustls cert fetch failed, using raw TLS metadata only");
Ok(raw)
} else if let Some(raw_err) = raw_last_error {
Err(anyhow!("TLS fetch failed (raw: {raw_err}; rustls: {err})"))
} else {
Err(err)
}
}
}
}
/// Fetch real TLS metadata for the given SNI using a single-attempt compatibility strategy.
#[allow(dead_code)]
pub async fn fetch_real_tls( pub async fn fetch_real_tls(
host: &str, host: &str,
port: u16, port: u16,
@@ -756,62 +1263,30 @@ pub async fn fetch_real_tls(
proxy_protocol: u8, proxy_protocol: u8,
unix_sock: Option<&str>, unix_sock: Option<&str>,
) -> Result<TlsFetchResult> { ) -> Result<TlsFetchResult> {
let raw_result = match fetch_via_raw_tls( let strategy = TlsFetchStrategy::single_attempt(connect_timeout);
fetch_real_tls_with_strategy(
host, host,
port, port,
sni, sni,
connect_timeout, &strategy,
upstream.clone(),
scope,
proxy_protocol,
unix_sock,
)
.await
{
Ok(res) => Some(res),
Err(e) => {
warn!(sni = %sni, error = %e, "Raw TLS fetch failed");
None
}
};
match fetch_via_rustls(
host,
port,
sni,
connect_timeout,
upstream, upstream,
scope, scope,
proxy_protocol, proxy_protocol,
unix_sock, unix_sock,
) )
.await .await
{
Ok(rustls_result) => {
if let Some(mut raw) = raw_result {
raw.cert_info = rustls_result.cert_info;
raw.cert_payload = rustls_result.cert_payload;
raw.behavior_profile.source = TlsProfileSource::Merged;
debug!(sni = %sni, "Fetched TLS metadata via raw probe + rustls cert chain");
Ok(raw)
} else {
Ok(rustls_result)
}
}
Err(e) => {
if let Some(raw) = raw_result {
warn!(sni = %sni, error = %e, "Rustls cert fetch failed, using raw TLS metadata only");
Ok(raw)
} else {
Err(e)
}
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{derive_behavior_profile, encode_tls13_certificate_message}; use std::time::{Duration, Instant};
use super::{
ProfileCacheValue, TlsFetchStrategy, build_client_hello, derive_behavior_profile,
encode_tls13_certificate_message, order_profiles, profile_cache, profile_cache_key,
};
use crate::config::TlsFetchProfile;
use crate::crypto::SecureRandom;
use crate::protocol::constants::{ use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
}; };
@@ -860,4 +1335,93 @@ mod tests {
assert_eq!(profile.ticket_record_sizes, vec![220, 180]); assert_eq!(profile.ticket_record_sizes, vec![220, 180]);
assert_eq!(profile.source, TlsProfileSource::Raw); assert_eq!(profile.source, TlsProfileSource::Raw);
} }
#[test]
fn test_order_profiles_prioritizes_fresh_cached_winner() {
let strategy = TlsFetchStrategy {
profiles: vec![
TlsFetchProfile::ModernChromeLike,
TlsFetchProfile::CompatTls12,
TlsFetchProfile::LegacyMinimal,
],
strict_route: true,
attempt_timeout: Duration::from_secs(1),
total_budget: Duration::from_secs(2),
grease_enabled: false,
deterministic: false,
profile_cache_ttl: Duration::from_secs(60),
};
let cache_key = profile_cache_key(
"mask.example",
443,
"tls.example",
None,
Some("tls"),
0,
None,
);
profile_cache().remove(&cache_key);
profile_cache().insert(
cache_key.clone(),
ProfileCacheValue {
profile: TlsFetchProfile::CompatTls12,
updated_at: Instant::now(),
},
);
let ordered = order_profiles(&strategy, Some(&cache_key), Instant::now());
assert_eq!(ordered[0], TlsFetchProfile::CompatTls12);
profile_cache().remove(&cache_key);
}
#[test]
fn test_order_profiles_drops_expired_cached_winner() {
let strategy = TlsFetchStrategy {
profiles: vec![
TlsFetchProfile::ModernFirefoxLike,
TlsFetchProfile::CompatTls12,
],
strict_route: true,
attempt_timeout: Duration::from_secs(1),
total_budget: Duration::from_secs(2),
grease_enabled: false,
deterministic: false,
profile_cache_ttl: Duration::from_secs(5),
};
let cache_key =
profile_cache_key("mask2.example", 443, "tls2.example", None, None, 0, None);
profile_cache().remove(&cache_key);
profile_cache().insert(
cache_key.clone(),
ProfileCacheValue {
profile: TlsFetchProfile::CompatTls12,
updated_at: Instant::now() - Duration::from_secs(6),
},
);
let ordered = order_profiles(&strategy, Some(&cache_key), Instant::now());
assert_eq!(ordered[0], TlsFetchProfile::ModernFirefoxLike);
assert!(profile_cache().get(&cache_key).is_none());
}
#[test]
fn test_deterministic_client_hello_is_stable() {
let rng = SecureRandom::new();
let first = build_client_hello(
"stable.example",
&rng,
TlsFetchProfile::ModernChromeLike,
true,
true,
);
let second = build_client_hello(
"stable.example",
&rng,
TlsFetchProfile::ModernChromeLike,
true,
true,
);
assert_eq!(first, second);
}
} }
+4 -1
View File
@@ -27,7 +27,10 @@ pub(crate) struct HttpsGetResponse {
fn build_tls_client_config() -> Arc<rustls::ClientConfig> { fn build_tls_client_config() -> Arc<rustls::ClientConfig> {
let mut root_store = rustls::RootCertStore::empty(); let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder() let provider = rustls::crypto::ring::default_provider();
let config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
.with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])
.expect("HTTPS fetch rustls protocol versions must be valid")
.with_root_certificates(root_store) .with_root_certificates(root_store)
.with_no_client_auth(); .with_no_client_auth();
Arc::new(config) Arc::new(config)