mirror of
https://github.com/telemt/telemt.git
synced 2026-07-13 15:30:20 +03:00
Compare commits
27 Commits
655a08fa5c
..
3.3.31
| Author | SHA1 | Date | |
|---|---|---|---|
| c43de1bd2a | |||
| 101efe45b7 | |||
| 11df61c6ac | |||
| 08684bcbd2 | |||
| 744fb4425f | |||
| 80cb1bc221 | |||
| 8461556b02 | |||
| cfd516edf3 | |||
| 803c2c0492 | |||
| b762bd029f | |||
| 761679d306 | |||
| 41668b153d | |||
| 1d2f88ad29 | |||
| 80917f5abc | |||
| dc61d300ab | |||
| ae16080de5 | |||
| b8ca1fc166 | |||
| f9986944df | |||
| cb877c2bc3 | |||
| 4426082c17 | |||
| 22097f8c7c | |||
| 1450af60a0 | |||
| f1cc8d65f2 | |||
| ec7e808daf | |||
| e4b7e23e76 | |||
| 8b92b80b4a | |||
| f7868aa00f |
+220
-66
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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#"
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user